diff --git a/apps/web/src/lib/server/interpret-write-turn.test.ts b/apps/web/src/lib/server/interpret-write-turn.test.ts new file mode 100644 index 0000000..da35e85 --- /dev/null +++ b/apps/web/src/lib/server/interpret-write-turn.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@atlas/integrations", () => ({ + interpretWriteTurnWithResponses: vi.fn(), +})); + +import { interpretWriteTurnWithResponses } from "@atlas/integrations"; + +import { interpretWriteTurn } from "./interpret-write-turn"; + +const mockInterpretWriteTurnWithResponses = vi.mocked( + interpretWriteTurnWithResponses, +); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("interpretWriteTurn", () => { + it("forwards entityContext to the integrations layer", async () => { + mockInterpretWriteTurnWithResponses.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: { entityId: "task-1", description: null, entityKind: null }, + taskName: null, + fields: { + scheduleFields: null, + taskFields: null, + }, + confidence: {}, + unresolvedFields: [], + }); + + await interpretWriteTurn({ + currentTurnText: "move gym", + turnType: "edit_request", + entityContext: 'Known entities:\n- "Gym" (task, scheduled) [id: task-1]', + }); + + expect(mockInterpretWriteTurnWithResponses).toHaveBeenCalledWith( + expect.objectContaining({ + entityContext: + 'Known entities:\n- "Gym" (task, scheduled) [id: task-1]', + }), + undefined, + ); + }); + + it("falls back cleanly when the integrations layer returns malformed output", async () => { + mockInterpretWriteTurnWithResponses.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { + scheduleFields: null, + taskFields: null, + }, + confidence: { + bad: 2, + }, + unresolvedFields: [], + } as never); + + await expect( + interpretWriteTurn({ + currentTurnText: "schedule gym", + turnType: "planning_request", + }), + ).resolves.toMatchObject({ + operationKind: "plan", + targetRef: null, + sourceText: "schedule gym", + }); + }); +}); diff --git a/apps/web/src/lib/server/turn-router.test.ts b/apps/web/src/lib/server/turn-router.test.ts index 1351434..41ce277 100644 --- a/apps/web/src/lib/server/turn-router.test.ts +++ b/apps/web/src/lib/server/turn-router.test.ts @@ -436,6 +436,107 @@ describe("turn router", () => { expect(mockInterpretWriteTurn).not.toHaveBeenCalled(); }); + it("prefers the interpreter targetRef entity over discourse focus for write turns", async () => { + mockClassification({ + turnType: "edit_request", + confidence: 0.96, + }); + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "edit", + actionDomain: "task", + targetRef: { entityId: "task-2" }, + taskName: null, + fields: { scheduleFields: { time: t(11, 0) } }, + sourceText: "Move weekly review to 11", + confidence: { + "scheduleFields.time": 0.95, + }, + unresolvedFields: [], + }); + + const result = await routeMessageTurn({ + rawText: "Move weekly review to 11", + normalizedText: "Move weekly review to 11", + recentTurns: [], + tasks: [ + { + id: "task-2", + userId: "user-1", + sourceInboxItemId: "inbox-1", + lastInboxItemId: "inbox-1", + title: "Weekly review", + lifecycleState: "pending_schedule", + externalCalendarEventId: null, + externalCalendarId: null, + scheduledStartAt: null, + scheduledEndAt: null, + calendarSyncStatus: "in_sync", + calendarSyncUpdatedAt: null, + rescheduleCount: 0, + lastFollowupAt: null, + followupReminderSentAt: null, + completedAt: null, + archivedAt: null, + priority: "medium", + urgency: "medium", + }, + ], + discourseState: { + focus_entity_id: "task-1", + currently_editable_entity_id: "task-1", + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [], + mode: "editing", + }, + }); + + expect(result.interpretation.resolvedEntityIds).toEqual(["task-2"]); + expect(result.policy.targetEntityId).toBe("task-2"); + expect(result.policy.resolvedOperation?.targetRef).toEqual({ + entityId: "task-2", + }); + }); + + it("falls back to resolveWriteTarget when the interpreter does not resolve an entity", async () => { + mockClassification({ + turnType: "edit_request", + confidence: 0.96, + }); + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "edit", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { scheduleFields: { time: t(11, 0) } }, + sourceText: "Move it to 11", + confidence: { + "scheduleFields.time": 0.95, + }, + unresolvedFields: [], + }); + + const result = await routeMessageTurn({ + rawText: "Move it to 11", + normalizedText: "Move it to 11", + recentTurns: [], + discourseState: { + focus_entity_id: "task-1", + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [], + mode: "editing", + }, + }); + + expect(result.interpretation.resolvedEntityIds).toEqual(["task-1"]); + expect(result.policy.targetEntityId).toBe("task-1"); + expect(result.policy.resolvedOperation?.targetRef).toEqual({ + entityId: "task-1", + }); + }); + it("clears prior committed fields when the interpreted workflow changes", async () => { mockClassification({ turnType: "planning_request", diff --git a/apps/web/src/lib/server/turn-router.ts b/apps/web/src/lib/server/turn-router.ts index 42ac65d..61d3805 100644 --- a/apps/web/src/lib/server/turn-router.ts +++ b/apps/web/src/lib/server/turn-router.ts @@ -1,13 +1,16 @@ import { applyWriteCommit, + buildEntityContext, type ConversationDiscourseState, type ConversationEntity, type ConversationTurn, createEmptyDiscourseState, deriveAmbiguity, type PendingWriteOperation, + renderEntityContext, type RoutedTurn, routedTurnSchema, + taskSchema, type TurnAmbiguity, type TurnClassifierOutput, type TurnInterpretation, @@ -75,6 +78,7 @@ export async function routeMessageTurn( ): Promise { const discourseState = input.discourseState ?? createEmptyDiscourseState(); const entityRegistry = input.entityRegistry ?? []; + const tasks = (input.tasks ?? []).map((task) => taskSchema.parse(task)); // Pipeline A: classify intent let classification = await classifyTurn({ @@ -122,6 +126,13 @@ export async function routeMessageTurn( turnType: classification.turnType, priorPendingWriteOperation: priorOperation, conversationContext: deriveConversationContext(input.recentTurns), + entityContext: renderEntityContext( + buildEntityContext({ + entityRegistry, + tasks, + discourseState, + }), + ), }) : { operationKind: priorOperation?.operationKind ?? "plan", @@ -134,13 +145,23 @@ export async function routeMessageTurn( unresolvedFields: [], }; + // LLM-resolved targetRef takes priority over discourse-state entity lookup + const effectiveTargetEntityId = + writeInterpretation.targetRef?.entityId ?? writeTarget.targetEntityId; + const effectiveWriteTarget: WriteTarget = { + ...writeTarget, + ...(effectiveTargetEntityId + ? { targetEntityId: effectiveTargetEntityId } + : {}), + }; + // Policy layer: commit + route const commitResult = applyWriteCommit({ turnType: classification.turnType, interpretation: writeInterpretation, priorPendingWriteOperation: priorOperation, - ...(writeTarget.targetEntityId !== undefined - ? { currentTargetEntityId: writeTarget.targetEntityId } + ...(effectiveTargetEntityId !== undefined + ? { currentTargetEntityId: effectiveTargetEntityId } : {}), }); @@ -148,7 +169,7 @@ export async function routeMessageTurn( classification, commitResult, routingContext: input, - ...writeTarget, + ...effectiveWriteTarget, }); // Assemble the resolved PendingWriteOperation for any turn that advances or maintains @@ -168,7 +189,7 @@ export async function routeMessageTurn( const interpretation = buildInterpretation( classification, commitResult, - writeTarget, + effectiveWriteTarget, ); return routedTurnSchema.parse({ diff --git a/packages/core/src/entity-context.test.ts b/packages/core/src/entity-context.test.ts new file mode 100644 index 0000000..9b6ea4a --- /dev/null +++ b/packages/core/src/entity-context.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; + +import { + buildEntityContext, + renderEntityContext, + type ConversationEntity, + type Task, +} from "./index"; + +function buildTask(overrides: Partial = {}): Task { + return { + id: "task-1", + userId: "user-1", + sourceInboxItemId: "inbox-1", + lastInboxItemId: "inbox-1", + title: "Gym session", + lifecycleState: "pending_schedule", + externalCalendarEventId: null, + externalCalendarId: null, + scheduledStartAt: null, + scheduledEndAt: null, + calendarSyncStatus: "in_sync", + calendarSyncUpdatedAt: null, + rescheduleCount: 0, + lastFollowupAt: null, + followupReminderSentAt: null, + completedAt: null, + archivedAt: null, + priority: "medium", + urgency: "medium", + ...overrides, + }; +} + +function buildEntity( + overrides: Partial, +): ConversationEntity { + return { + id: "entity-1", + conversationId: "conversation-1", + label: "Entity label", + status: "active", + createdAt: "2026-04-01T15:00:00.000Z", + updatedAt: "2026-04-01T15:00:00.000Z", + kind: "task", + data: { + taskId: "task-1", + title: "Gym session", + lifecycleState: "pending_schedule", + scheduledStartAt: null, + scheduledEndAt: null, + }, + ...overrides, + } as ConversationEntity; +} + +describe("entity context", () => { + it("builds filtered known entities and derives focus, proposal, and clarification helpers", () => { + const context = buildEntityContext({ + entityRegistry: [ + buildEntity({ + id: "task-entity-1", + label: "Gym session", + kind: "task", + data: { + taskId: "task-1", + title: "Gym session", + lifecycleState: "scheduled", + scheduledStartAt: "2026-04-02T01:00:00.000Z", + scheduledEndAt: "2026-04-02T02:00:00.000Z", + }, + }), + buildEntity({ + id: "proposal-1", + label: "Schedule gym tomorrow at 6pm", + kind: "proposal_option", + status: "presented", + data: { + route: "conversation_then_mutation", + replyText: "Would you like me to schedule it at 6pm?", + confirmationRequired: true, + targetEntityId: "task-entity-1", + mutationInputSource: null, + originatingTurnText: "Schedule gym tomorrow at 6pm", + missingFields: ["scheduleFields.time"], + fieldSnapshot: {}, + }, + }), + buildEntity({ + id: "clarification-1", + label: "What time?", + kind: "clarification", + data: { + prompt: "What time should I schedule it?", + reason: null, + open: true, + }, + }), + buildEntity({ + id: "block-1", + label: "Write blog post", + kind: "scheduled_block", + data: { + blockId: "block-db-1", + taskId: "task-2", + title: "Write blog post", + startAt: "2026-04-02T03:00:00.000Z", + endAt: "2026-04-02T04:00:00.000Z", + externalCalendarId: "primary", + }, + }), + buildEntity({ + id: "reminder-1", + label: "Review taxes", + kind: "reminder", + status: "active", + data: { + taskId: "task-3", + title: "Review taxes", + reminderKind: "reminder", + number: 1, + }, + }), + // These should be filtered out: + buildEntity({ + id: "done-task", + label: "Completed task", + kind: "task", + data: { + taskId: "task-done", + title: "Completed task", + lifecycleState: "done", + scheduledStartAt: null, + scheduledEndAt: null, + }, + }), + buildEntity({ + id: "resolved-proposal", + label: "Old proposal", + kind: "proposal_option", + status: "resolved", + data: { + route: "conversation_then_mutation", + replyText: "Old proposal", + confirmationRequired: true, + targetEntityId: null, + mutationInputSource: null, + fieldSnapshot: {}, + }, + }), + buildEntity({ + id: "closed-clarification", + label: "Closed clarification", + kind: "clarification", + data: { + prompt: "Closed clarification", + reason: null, + open: false, + }, + }), + ], + tasks: [ + buildTask({ id: "task-1", title: "Gym session duplicate" }), // matches registry by taskId, should be deduplicated + buildTask({ + id: "task-2", + title: "Weekly review", + lifecycleState: "awaiting_followup", + externalCalendarEventId: "event-1", + externalCalendarId: "primary", + scheduledStartAt: "2026-04-02T05:00:00.000Z", + scheduledEndAt: "2026-04-02T06:00:00.000Z", + }), + ], + discourseState: { + focus_entity_id: "task-entity-1", + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [], + mode: "planning", + }, + }); + + // Sorted by expectedType then label then id + expect(context.knownEntities).toEqual([ + { id: "clarification-1", label: "What time should I schedule it?", expectedType: "clarification", state: "open" }, + { id: "proposal-1", label: "Schedule gym tomorrow at 6pm", expectedType: "proposal", state: "presented" }, + { id: "reminder-1", label: "Review taxes", expectedType: "reminder", state: "active" }, + { id: "block-1", label: "Write blog post", expectedType: "scheduled_block", state: "scheduled" }, + { id: "task-entity-1", label: "Gym session", expectedType: "task", state: "scheduled" }, + { id: "task-2", label: "Weekly review", expectedType: "task", state: "awaiting_followup" }, + ]); + expect(context.focusedEntityId).toBe("task-entity-1"); + expect(context.activeProposal).toEqual({ + id: "proposal-1", + summary: "Schedule gym tomorrow at 6pm", + missingFields: ["scheduleFields.time"], + }); + expect(context.openClarification).toEqual({ + id: "clarification-1", + prompt: "What time should I schedule it?", + }); + }); + + it("renders deterministic prompt text with explicit empty sections", () => { + const rendered = renderEntityContext({ + knownEntities: [ + { + id: "task-1", + label: "Gym session", + expectedType: "task", + state: "scheduled", + }, + ], + focusedEntityId: "task-1", + activeProposal: null, + openClarification: null, + }); + + expect(rendered).toBe( + 'Known entities:\n- "Gym session" (task, scheduled) [id: task-1]\n\nCurrently focused: "Gym session" [id: task-1]\n\nNo active proposal.\n\nNo open clarification.', + ); + }); + + it("renders an explicit no-known-entities line when the context is empty", () => { + expect( + renderEntityContext({ + knownEntities: [], + focusedEntityId: null, + activeProposal: null, + openClarification: null, + }), + ).toBe( + "Known entities:\nNo known entities.\n\nNo focused entity.\n\nNo active proposal.\n\nNo open clarification.", + ); + }); +}); diff --git a/packages/core/src/entity-context.ts b/packages/core/src/entity-context.ts new file mode 100644 index 0000000..397be50 --- /dev/null +++ b/packages/core/src/entity-context.ts @@ -0,0 +1,228 @@ +import type { ConversationDiscourseState } from "./discourse-state"; +import type { ConversationEntity, Task } from "./index"; + +export type EntityContextEntry = { + id: string; + label: string; + expectedType: + | "task" + | "proposal" + | "clarification" + | "scheduled_block" + | "reminder"; + state: string; +}; + +export type EntityContext = { + knownEntities: EntityContextEntry[]; + focusedEntityId: string | null; + activeProposal: { id: string; summary: string; missingFields?: string[] } | null; + openClarification: { id: string; prompt: string } | null; +}; + +type BuildEntityContextInput = { + entityRegistry: ConversationEntity[]; + tasks: Task[]; + discourseState: ConversationDiscourseState | null; +}; + +const INACTIVE_TASK_STATES = new Set(["done", "archived"]); +const INACTIVE_PROPOSAL_STATES = new Set(["resolved", "superseded"]); + +export function buildEntityContext( + input: BuildEntityContextInput, +): EntityContext { + const registryTaskIds = new Set( + input.entityRegistry + .filter( + (entity): entity is Extract => + entity.kind === "task", + ) + .map((entity) => entity.data.taskId), + ); + + const knownEntities: EntityContextEntry[] = []; + + for (const entity of input.entityRegistry) { + switch (entity.kind) { + case "task": + if (!INACTIVE_TASK_STATES.has(entity.data.lifecycleState)) { + knownEntities.push({ + id: entity.id, + label: entity.data.title, + expectedType: "task", + state: entity.data.lifecycleState, + }); + } + break; + case "proposal_option": + if (!INACTIVE_PROPOSAL_STATES.has(entity.status)) { + knownEntities.push({ + id: entity.id, + label: + entity.data.originatingTurnText ?? + entity.data.replyText ?? + entity.label, + expectedType: "proposal", + state: entity.status, + }); + } + break; + case "clarification": + if (entity.data.open) { + knownEntities.push({ + id: entity.id, + label: entity.data.prompt, + expectedType: "clarification", + state: "open", + }); + } + break; + case "scheduled_block": + knownEntities.push({ + id: entity.id, + label: entity.data.title, + expectedType: "scheduled_block", + state: "scheduled", + }); + break; + case "reminder": + knownEntities.push({ + id: entity.id, + label: entity.data.title, + expectedType: "reminder", + state: entity.status, + }); + break; + } + } + + for (const task of input.tasks) { + if ( + !registryTaskIds.has(task.id) && + !INACTIVE_TASK_STATES.has(task.lifecycleState) + ) { + knownEntities.push({ + id: task.id, + label: task.title, + expectedType: "task", + state: task.lifecycleState, + }); + } + } + + knownEntities.sort( + (left, right) => + left.expectedType.localeCompare(right.expectedType) || + left.label.localeCompare(right.label) || + left.id.localeCompare(right.id), + ); + + const knownEntityIds = new Set(knownEntities.map((entity) => entity.id)); + const focusedEntityId = + input.discourseState?.focus_entity_id && + knownEntityIds.has(input.discourseState.focus_entity_id) + ? input.discourseState.focus_entity_id + : null; + + const activeProposals = input.entityRegistry.filter( + ( + entity, + ): entity is Extract => + entity.kind === "proposal_option" && + !INACTIVE_PROPOSAL_STATES.has(entity.status), + ); + const singleActiveProposal = + activeProposals.length === 1 ? activeProposals[0] : null; + const activeProposal = + singleActiveProposal + ? { + id: singleActiveProposal.id, + summary: + singleActiveProposal.data.originatingTurnText ?? + singleActiveProposal.data.replyText, + ...(singleActiveProposal.data.missingFields?.length + ? { missingFields: singleActiveProposal.data.missingFields } + : {}), + } + : null; + + const openClarifications = input.entityRegistry.filter( + ( + entity, + ): entity is Extract => + entity.kind === "clarification" && entity.data.open, + ); + const singleOpenClarification = + openClarifications.length === 1 ? openClarifications[0] : null; + const openClarification = + singleOpenClarification + ? { + id: singleOpenClarification.id, + prompt: singleOpenClarification.data.prompt, + } + : null; + + return { + knownEntities, + focusedEntityId, + activeProposal, + openClarification, + }; +} + +export function renderEntityContext(context: EntityContext): string { + const lines = ["Known entities:"]; + + if (context.knownEntities.length === 0) { + lines.push("No known entities."); + } else { + for (const entity of context.knownEntities) { + lines.push( + `- "${entity.label}" (${entity.expectedType}, ${entity.state}) [id: ${entity.id}]`, + ); + } + } + + lines.push(""); + + if (context.focusedEntityId) { + const focusedEntity = context.knownEntities.find( + (entity) => entity.id === context.focusedEntityId, + ); + lines.push( + focusedEntity + ? `Currently focused: "${focusedEntity.label}" [id: ${focusedEntity.id}]` + : "No focused entity.", + ); + } else { + lines.push("No focused entity."); + } + + lines.push(""); + + if (context.activeProposal) { + const missingFields = + context.activeProposal.missingFields && + context.activeProposal.missingFields.length > 0 + ? ` - still needs: ${context.activeProposal.missingFields.join(", ")}` + : ""; + lines.push( + `Active proposal: "${context.activeProposal.summary}"${missingFields} [id: ${context.activeProposal.id}]`, + ); + } else { + lines.push("No active proposal."); + } + + lines.push(""); + + if (context.openClarification) { + lines.push( + `Open clarification: "${context.openClarification.prompt}" [id: ${context.openClarification.id}]`, + ); + } else { + lines.push("No open clarification."); + } + + return lines.join("\n"); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index adf8435..eb237d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ import { export * from "./ambiguity"; export * from "./commit-policy"; export * from "./discourse-state"; +export * from "./entity-context"; export * from "./proposal-rules"; export * from "./slot-normalizer"; export * from "./synthesize-mutation-text"; @@ -904,6 +905,7 @@ export const turnRoutingInputSchema = z.object({ summaryText: z.string().nullable().optional(), entityRegistry: z.array(conversationEntitySchema).optional().default([]), discourseState: conversationDiscourseStateSchema.nullable().optional(), + tasks: z.array(taskSchema).optional().default([]), }); export const turnRoutingOutputSchema = z.object({ @@ -1052,6 +1054,7 @@ export const writeInterpretationInputSchema = z.object({ turnType: turnInterpretationTypeSchema, priorPendingWriteOperation: pendingWriteOperationSchema.optional(), conversationContext: z.string().optional(), + entityContext: z.string().optional(), }); export const rawWriteInterpretationSchema = z.object({ diff --git a/packages/integrations/src/index.test.ts b/packages/integrations/src/index.test.ts index fcd3c76..64805fd 100644 --- a/packages/integrations/src/index.test.ts +++ b/packages/integrations/src/index.test.ts @@ -4,6 +4,7 @@ import { } from "@atlas/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { interpretWriteTurnSystemPrompt } from "./prompts/interpret-write-turn"; import { buildGoogleCalendarOAuthUrl, createGoogleCalendarAdapter, @@ -1153,4 +1154,67 @@ describe("integrations", () => { "Telegram editMessageText failed with status 400: Bad Request: message can't be edited.", ); }); + + it("includes entityContext in the write interpretation prompt payload and prompt instructions", async () => { + const parse = vi.fn(async () => ({ + output_parsed: { + operationKind: "edit", + actionDomain: "task", + targetRef: { + entityId: "task-1", + description: null, + entityKind: null, + }, + taskName: null, + fields: { + scheduleFields: null, + taskFields: null, + }, + confidence: {}, + unresolvedFields: [], + }, + })); + + await interpretWriteTurnWithResponses( + { + currentTurnText: "Move gym to 5", + turnType: "edit_request", + entityContext: + 'Known entities:\n- "Gym" (task, scheduled) [id: task-1]', + }, + { + responses: { + parse, + }, + }, + ); + + expect(parse).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.arrayContaining([ + expect.objectContaining({ + role: "system", + content: expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining( + "Resolve entity references only against the provided entityContext.", + ), + }), + ]), + }), + expect.objectContaining({ + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining("Entity context:\nKnown entities:"), + }), + ]), + }), + ]), + }), + ); + expect(interpretWriteTurnSystemPrompt).toContain( + "Never invent an entity ID", + ); + }); }); diff --git a/packages/integrations/src/openai.ts b/packages/integrations/src/openai.ts index 3821f96..ffab0be 100644 --- a/packages/integrations/src/openai.ts +++ b/packages/integrations/src/openai.ts @@ -330,7 +330,7 @@ export async function interpretWriteTurnWithResponses( content: [ { type: "input_text", - text: JSON.stringify(buildWriteInterpretationPromptContext(context)), + text: buildWriteInterpretationPromptContext(context), }, ], }, @@ -413,12 +413,24 @@ function buildSlotExtractorPromptContext(context: SlotExtractorInput) { function buildWriteInterpretationPromptContext( context: WriteInterpretationInput, ) { - return { - currentTurnText: context.currentTurnText, - turnType: context.turnType, - priorPendingWriteOperation: context.priorPendingWriteOperation ?? null, - conversationContext: context.conversationContext ?? null, - }; + return [ + "Current turn text:", + context.currentTurnText, + "", + "Turn type:", + context.turnType, + "", + "Prior pending write operation:", + context.priorPendingWriteOperation + ? JSON.stringify(context.priorPendingWriteOperation, null, 2) + : "None", + "", + "Conversation context:", + context.conversationContext ?? "None", + "", + "Entity context:", + context.entityContext ?? "None", + ].join("\n"); } function buildTurnRoutingPromptContext(context: TurnRoutingInput) { diff --git a/packages/integrations/src/prompts/interpret-write-turn.ts b/packages/integrations/src/prompts/interpret-write-turn.ts index 5dce5fb..8b4a6b5 100644 --- a/packages/integrations/src/prompts/interpret-write-turn.ts +++ b/packages/integrations/src/prompts/interpret-write-turn.ts @@ -14,6 +14,7 @@ export const interpretWriteTurnSystemPrompt = buildPromptSpec([ "Infer the operation kind expressed by the user, extract any concrete write fields, and report uncertainty per field path.", "Describe what the user said in this turn. Do not decide whether Atlas should execute, clarify, or ask for consent.", "Use priorPendingWriteOperation only for continuity when the turn is a follow-up clarification or continuation.", + "Use entityContext to resolve references to known entities when the user points at existing work, proposals, reminders, or schedule blocks.", ], }, { @@ -43,6 +44,16 @@ export const interpretWriteTurnSystemPrompt = buildPromptSpec([ "When the user is vague like 'whenever' or 'you pick', do not guess. Put the field path in unresolvedFields.", ], }, + { + title: "Entity Resolution", + lines: [ + "Resolve entity references only against the provided entityContext.", + "If you identify a known entity, copy its exact [id: ...] value into targetRef.entityId.", + "Never invent an entity ID that is not present in entityContext.", + "When the user is introducing new work rather than referring to a known entity, return targetRef: null and populate taskName if they named the work.", + "If the turn continues a prior write without naming a new entity, you may leave targetRef null.", + ], + }, { title: "Important Constraints", lines: [