@inflexa-ai/harness 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/agents/conversation-agent.js +3 -1
  2. package/dist/contracts/message.d.ts +6 -0
  3. package/dist/execution/report-runner.d.ts +6 -1
  4. package/dist/execution/report-runner.js +1 -0
  5. package/dist/execution/run-synthesis.d.ts +7 -0
  6. package/dist/execution/run-synthesis.js +8 -2
  7. package/dist/index.d.ts +1 -1
  8. package/dist/loop/run-agent.d.ts +1 -1
  9. package/dist/loop/run-agent.js +55 -2
  10. package/dist/memory/ai-sdk-message-storage.d.ts +42 -0
  11. package/dist/memory/ai-sdk-message-storage.js +62 -0
  12. package/dist/memory/content-to-cortex.d.ts +8 -1
  13. package/dist/memory/content-to-cortex.js +36 -4
  14. package/dist/memory/thread-history.d.ts +37 -0
  15. package/dist/memory/thread-history.js +74 -7
  16. package/dist/prompts/conversation.d.ts +1 -1
  17. package/dist/prompts/conversation.js +3 -3
  18. package/dist/prompts/report-builder.d.ts +1 -1
  19. package/dist/prompts/report-builder.js +1 -1
  20. package/dist/providers/streaming-chat.d.ts +11 -2
  21. package/dist/providers/streaming-chat.js +31 -6
  22. package/dist/providers/types.d.ts +10 -1
  23. package/dist/schemas/validate-plan.js +10 -5
  24. package/dist/tools/iterate-report.d.ts +0 -1
  25. package/dist/tools/iterate-report.js +125 -11
  26. package/dist/tools/report/mint-preview-url.d.ts +1 -1
  27. package/dist/tools/report/mint-preview-url.js +7 -5
  28. package/dist/tools/report/preview-publisher.d.ts +16 -0
  29. package/dist/tools/report/preview-publisher.js +20 -0
  30. package/dist/tools/report/preview-snapshot.d.ts +9 -1
  31. package/dist/tools/report/preview-snapshot.js +15 -1
  32. package/dist/tools/report/submit-report.d.ts +1 -0
  33. package/dist/tools/report/submit-report.js +32 -11
  34. package/dist/tools/report/version-fs.d.ts +4 -4
  35. package/dist/tools/report/version-fs.js +24 -17
  36. package/dist/tools/research/inspect-run.js +10 -3
  37. package/dist/tools/workspace/read-file.d.ts +5 -0
  38. package/dist/tools/workspace/read-file.js +20 -2
  39. package/dist/workflows/execute-analysis.d.ts +14 -1
  40. package/dist/workflows/execute-analysis.js +105 -8
  41. package/dist/workspace/paths.d.ts +10 -0
  42. package/dist/workspace/paths.js +10 -0
  43. package/package.json +1 -1
@@ -32,7 +32,7 @@ import { createWorkingMemory } from "../memory/working-memory.js";
32
32
  import { conversationPrompt } from "../prompts/conversation.js";
33
33
  import { composeSystemPrompt } from "./system-prompt.js";
34
34
  // Bio-lookup leaf tools (pure — no dependencies).
35
- import { searchGeneTool, searchPathwayTool, lookupGoTermTool, searchInteractionsTool, chemblTool, pubchemTool, openTargetsTool, searchPharmgkbTool, searchFaersTool, searchClinicalTrialsTool, searchGeoDatasetsTool, createSearchDgidbTool, searchBgeeExpressionTool, getImpcKoProfileTool, checkSafetyPanelTool, } from "../tools/bio/index.js";
35
+ import { searchGeneTool, searchPathwayTool, lookupGoTermTool, searchInteractionsTool, chemblTool, pubchemTool, openTargetsTool, searchGwasCatalogTool, searchPharmgkbTool, searchFaersTool, searchClinicalTrialsTool, searchGeoDatasetsTool, createSearchDgidbTool, searchBgeeExpressionTool, getImpcKoProfileTool, checkSafetyPanelTool, } from "../tools/bio/index.js";
36
36
  import { createNcbiTools, createChemDbTools } from "../tools/bio/keys.js";
37
37
  // Dependency-bearing tool factories.
38
38
  import { createGeneratePlanTool, createLiteratureReviewerTool, createInspectRunTool, createInspectDataProfileTool, createGenerateAnalogyReportTool, } from "../tools/research/index.js";
@@ -68,6 +68,8 @@ export function createConversationAgent(deps) {
68
68
  pubchemTool,
69
69
  // Translational medicine.
70
70
  openTargetsTool,
71
+ // Genetic evidence — SNP-trait associations for target validation / MR support.
72
+ searchGwasCatalogTool,
71
73
  searchPharmgkbTool,
72
74
  searchFaersTool,
73
75
  searchClinicalTrialsTool,
@@ -33,4 +33,10 @@ export interface CortexMessage {
33
33
  id: string;
34
34
  role: "user" | "assistant" | "system";
35
35
  parts: CortexPart[];
36
+ /**
37
+ * Set when this message's production was cut off by a client abort, so the UI
38
+ * can badge it. Absent means not interrupted — the field is optional so every
39
+ * existing consumer is unaffected.
40
+ */
41
+ interrupted?: boolean;
36
42
  }
@@ -60,7 +60,12 @@ export interface ReportRunnerOptions {
60
60
  readonly previewId: string;
61
61
  /** Base version to iterate from. Defaults to latest. */
62
62
  readonly baseVersion?: number;
63
- /** Report format. */
63
+ /**
64
+ * Format the preview was built as. The tool boundary admits only `"html"`,
65
+ * so no other value can reach a live iteration; the union stays wide because
66
+ * previews persisted with a broader vocabulary must still parse. Read this
67
+ * field as a record of what a preview is, not as a request for a renderer.
68
+ */
64
69
  readonly format: "html" | "pdf";
65
70
  /** Prompt for the report-builder agent. */
66
71
  readonly prompt: string;
@@ -128,6 +128,7 @@ export async function runReportIteration(deps, opts) {
128
128
  previews: opts.previews,
129
129
  urlCell,
130
130
  chrome: deps.chrome,
131
+ logger,
131
132
  }),
132
133
  ...createVersionFsTools({ versionDir: versionDirAbs }),
133
134
  ...Object.values(createSkillTools({ skillsDir: deps.skillsDir, skills: [...REPORT_BUILDER_SKILLS] })),
@@ -30,6 +30,13 @@ import type { ChatProvider } from "../providers/types.js";
30
30
  import type { RunSession } from "../auth/types.js";
31
31
  import { type Tool } from "../tools/define-tool.js";
32
32
  import type { BioToolKeys } from "../tools/bio/keys.js";
33
+ /**
34
+ * The synthesizer's agent identity — the `source.agentId` on its loop events
35
+ * and the `agent_id` of the run-phase `synthesis` row `executeAnalysis` writes
36
+ * to `cortex_step_executions`. One definition so the ledger row and the stream
37
+ * events can never disagree about who did the work.
38
+ */
39
+ export declare const SYNTHESIS_AGENT_ID = "run-synthesizer";
33
40
  type SynthesizerOutcome = {
34
41
  kind: "submitted";
35
42
  synthesis: RunSynthesis;
@@ -39,7 +39,13 @@ import { defineTool } from "../tools/define-tool.js";
39
39
  import { createReportBlockerToolFor } from "../tools/sandbox/report-blocker.js";
40
40
  import { createLiteratureReviewerTool } from "../tools/research/literature-reviewer.js";
41
41
  // ── Agent identity / budgets ────────────────────────────────────────
42
- const AGENT_ID = "run-synthesizer";
42
+ /**
43
+ * The synthesizer's agent identity — the `source.agentId` on its loop events
44
+ * and the `agent_id` of the run-phase `synthesis` row `executeAnalysis` writes
45
+ * to `cortex_step_executions`. One definition so the ledger row and the stream
46
+ * events can never disagree about who did the work.
47
+ */
48
+ export const SYNTHESIS_AGENT_ID = "run-synthesizer";
43
49
  /**
44
50
  * Budget for the synthesizer loop: read summaries + 1–3 reviewer delegations
45
51
  * + draft + 1–2 submit/fix cycles + headroom.
@@ -349,7 +355,7 @@ export async function generateRunSynthesis(input) {
349
355
  const blockerTool = buildBlockerTool(holder);
350
356
  const tools = [validateTool, submitTool, blockerTool, reviewer];
351
357
  const agent = {
352
- id: AGENT_ID,
358
+ id: SYNTHESIS_AGENT_ID,
353
359
  systemPrompt: composeSystemPrompt(synthesisAgentPrompt),
354
360
  model: input.model,
355
361
  tools,
package/dist/index.d.ts CHANGED
@@ -53,7 +53,7 @@ export type { PrepareChatTurnDeps, PrepareChatTurnParams, PrepareChatTurnResult
53
53
  export { createThreadStore } from "./memory/thread-store.js";
54
54
  export type { ThreadStore, Thread, CreateThreadInput, ListThreadsInput, ThreadPage } from "./memory/thread-store.js";
55
55
  export { createThreadHistory } from "./memory/thread-history.js";
56
- export type { ThreadHistory, StoredMessage, MessagePage } from "./memory/thread-history.js";
56
+ export type { ThreadHistory, StoredMessage, MessagePage, RetractOutcome } from "./memory/thread-history.js";
57
57
  export { contentToCortexMessages } from "./memory/content-to-cortex.js";
58
58
  export { createCardResolver } from "./memory/reconstruct-cards.js";
59
59
  export type { ToolCardResolver, StoredToolCallForCard } from "./memory/reconstruct-cards.js";
@@ -4,7 +4,7 @@ import type { AgentChat, PromptCachePolicy } from "../providers/types.js";
4
4
  import { type AskApproval, type AskRequest } from "../tools/approval/contract.js";
5
5
  import type { AgentDefinition, EmitFn, LoopMessage, RunStep } from "./types.js";
6
6
  export interface AgentFinish {
7
- readonly reason: FinishReason | "max_iterations" | "denied";
7
+ readonly reason: FinishReason | "aborted" | "max_iterations" | "denied";
8
8
  readonly cappedOut: boolean;
9
9
  readonly truncationRecoveries: number;
10
10
  }
@@ -1,5 +1,6 @@
1
1
  import { jsonSchema, tool as aiTool } from "ai";
2
2
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
3
+ import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
3
4
  import { classifyProviderError } from "../providers/errors.js";
4
5
  import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
5
6
  import { resultStep } from "./run-step.js";
@@ -72,13 +73,31 @@ export async function runAgent(agent, initial, session, opts) {
72
73
  };
73
74
  const reply = await resultStep(runStep)(formatStepName.llm(i), () => provider.chat(request, session, signal));
74
75
  addChatUsage(usage, reply.usage);
75
- messages.push(reply.message);
76
+ if (reply.finishReason === "aborted") {
77
+ // An interrupted turn keeps whatever the model produced before the cut, but
78
+ // never an empty shell: a partial with no content adds no message, so a
79
+ // no-output abort leaves the transcript at the initial prefix. The marker
80
+ // then rides the last assistant message this run produced — the partial when
81
+ // it has content, or the tool-calling step when the abort landed mid-dispatch
82
+ // — an assistant role no turn-boundary reader observes. "aborted" is not
83
+ // "tool-calls", so this falls into the terminal return below.
84
+ if (assistantHasContent(reply.message))
85
+ messages.push(reply.message);
86
+ markLastLoopAssistant(messages, initial.length);
87
+ }
88
+ else {
89
+ messages.push(reply.message);
90
+ }
76
91
  const toolCalls = toolCallParts(reply.message);
77
92
  if (reply.finishReason === "length") {
78
93
  truncationRecoveries++;
79
94
  await emit({ type: "iteration", source, index: i, final: false });
80
95
  if (toolCalls.length === 0) {
81
- messages.push({ role: "user", content: TRUNCATED_PROSE_STEER });
96
+ // Stamped synthetic, not left as a bare `user` message: the wire format needs a user turn
97
+ // after a truncated assistant message, but this one is the loop's own nudge, and thread
98
+ // storage treats a genuine `user` message as the start of a conversation turn. Unmarked, it
99
+ // would split this turn in two everywhere that boundary is read.
100
+ messages.push(syntheticUserMessage(TRUNCATED_PROSE_STEER));
82
101
  continue;
83
102
  }
84
103
  const trailing = toolCalls[toolCalls.length - 1];
@@ -135,6 +154,20 @@ export async function runAgent(agent, initial, session, opts) {
135
154
  // cache_write_tokens counter is what makes that waste visible.
136
155
  const wrapUp = await resultStep(runStep)(formatStepName.llm(agent.maxIterations), () => provider.chat({ system: agent.systemPrompt, messages, tools: {}, toolChoice: "none", providerOptions }, session, signal));
137
156
  addChatUsage(usage, wrapUp.usage);
157
+ if (wrapUp.finishReason === "aborted") {
158
+ // An abort during the tool-less wrap-up is still the user cutting the turn — the
159
+ // same event the in-loop path handles — so it gets the identical treatment: keep a
160
+ // partial only when it carries content, and stamp the marker on the last assistant
161
+ // this run produced. Reporting it as a plain cap-out would hide the interruption
162
+ // from every downstream reader; `cappedOut` stays true because the loop genuinely
163
+ // exhausted its iterations, while the reason carries the abort.
164
+ if (assistantHasContent(wrapUp.message))
165
+ messages.push(wrapUp.message);
166
+ markLastLoopAssistant(messages, initial.length);
167
+ await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
168
+ recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
169
+ return { messages, finish: { reason: "aborted", cappedOut: true, truncationRecoveries } };
170
+ }
138
171
  messages.push(wrapUp.message);
139
172
  await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
140
173
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
@@ -145,6 +178,26 @@ function toolCallParts(message) {
145
178
  return [];
146
179
  return message.content.filter((part) => part.type === "tool-call");
147
180
  }
181
+ /** Whether an aborted partial carries any content worth persisting — an empty partial contributes no message. */
182
+ function assistantHasContent(message) {
183
+ return message.content.length > 0;
184
+ }
185
+ /**
186
+ * Stamp the interruption marker on the last assistant message the loop produced
187
+ * this run — an index at or beyond the `initial` prefix — replacing the slot with
188
+ * a marked copy so the mark rides into `appendTurn` and the stored row. When the
189
+ * turn produced no assistant message beyond `initial` (a no-output abort on a
190
+ * fresh turn), there is nothing to mark and the transcript is left untouched.
191
+ */
192
+ function markLastLoopAssistant(messages, initialCount) {
193
+ for (let idx = messages.length - 1; idx >= initialCount; idx--) {
194
+ const message = messages[idx];
195
+ if (message.role === "assistant") {
196
+ messages[idx] = markInterruptedMessage(message);
197
+ return;
198
+ }
199
+ }
200
+ }
148
201
  async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, runStep, toolStepName) {
149
202
  const results = new Array(toolUses.length);
150
203
  const stepTools = [];
@@ -9,6 +9,48 @@ export declare const StoredMessageEnvelopeSchema: z.ZodObject<{
9
9
  export type StoredMessageEnvelope = z.infer<typeof StoredMessageEnvelopeSchema>;
10
10
  export declare function envelopeMessage(message: ModelMessage): StoredMessageEnvelope;
11
11
  export declare function parseStoredMessageEnvelope(value: unknown, identity: string): StoredMessageEnvelope;
12
+ /**
13
+ * The `providerOptions` namespace the harness stamps its OWN message metadata into.
14
+ *
15
+ * A provider SDK reads only its own namespace (`anthropic`, `openai`, …) and ignores every other key, so
16
+ * a harness-owned namespace rides to the wire and back untouched; `modelMessageSchema` validates
17
+ * `providerOptions` structurally rather than against a list of known namespaces, so it also survives the
18
+ * storage envelope round-trip. That round-trip is the whole reason the marker lives here rather than on
19
+ * the envelope: readers of STORED rows need it, and `ModelMessage` offers no other field able to carry a
20
+ * harness fact from the loop, through `appendTurn`, into a row.
21
+ */
22
+ export declare const HARNESS_PROVIDER_NAMESPACE = "cortex";
23
+ /** The {@link HARNESS_PROVIDER_NAMESPACE} key marking a message the loop synthesized rather than one a human sent. */
24
+ export declare const SYNTHETIC_MESSAGE_KEY = "synthetic";
25
+ /** The {@link HARNESS_PROVIDER_NAMESPACE} key marking an assistant message whose production a client abort cut off. */
26
+ export declare const INTERRUPTED_MESSAGE_KEY = "interrupted";
27
+ /**
28
+ * Return a copy of `message` carrying the interruption marker in the harness namespace.
29
+ *
30
+ * The marker lives in {@link HARNESS_PROVIDER_NAMESPACE} for the same reason the synthetic marker does:
31
+ * it is the only field of a `ModelMessage` that survives the loop → {@link envelopeMessage} → stored-row
32
+ * round trip, so a reader of stored rows can still tell an interrupted reply apart. It rides the
33
+ * **assistant** role, which is not a turn boundary, so no boundary reader (`isGenuineUserStart`, the
34
+ * tail-retract predicate, window snapping) can observe it. Any existing `providerOptions` namespace is
35
+ * preserved — a signed thinking signature or cache directive on the same message must not be dropped — so
36
+ * this merges into the harness namespace rather than replacing the whole `providerOptions`.
37
+ */
38
+ export declare function markInterruptedMessage(message: ModelMessage): ModelMessage;
39
+ /** Whether a client abort cut off this message's production (see {@link markInterruptedMessage}); an absent key reads as not interrupted. */
40
+ export declare function isInterruptedMessage(message: ModelMessage): boolean;
41
+ /**
42
+ * Build a `user` message the agent loop synthesized — today, the nudge that continues a reply the model
43
+ * truncated at its output-token limit.
44
+ *
45
+ * It has to carry the `user` role because the wire format requires a user turn after a truncated
46
+ * assistant message, but it is not user input, and the difference is load-bearing: a `user` message is
47
+ * what OPENS a conversation turn, so an unmarked synthetic one reads as a spurious turn boundary —
48
+ * splitting one turn into two for the token window and, worse, giving a tail-turn removal a cut point in
49
+ * the middle of a turn. Marking it is what lets {@link isSyntheticUserMessage} keep those readers honest.
50
+ */
51
+ export declare function syntheticUserMessage(text: string): ModelMessage;
52
+ /** Whether the loop synthesized this message (see {@link syntheticUserMessage}) rather than a human sending it. */
53
+ export declare function isSyntheticUserMessage(message: ModelMessage): boolean;
12
54
  type LegacyContent = string | Array<Record<string, unknown>>;
13
55
  export interface LegacyMessageRow {
14
56
  readonly threadId: string;
@@ -20,6 +20,68 @@ export function parseStoredMessageEnvelope(value, identity) {
20
20
  }
21
21
  return parsed.data;
22
22
  }
23
+ /**
24
+ * The `providerOptions` namespace the harness stamps its OWN message metadata into.
25
+ *
26
+ * A provider SDK reads only its own namespace (`anthropic`, `openai`, …) and ignores every other key, so
27
+ * a harness-owned namespace rides to the wire and back untouched; `modelMessageSchema` validates
28
+ * `providerOptions` structurally rather than against a list of known namespaces, so it also survives the
29
+ * storage envelope round-trip. That round-trip is the whole reason the marker lives here rather than on
30
+ * the envelope: readers of STORED rows need it, and `ModelMessage` offers no other field able to carry a
31
+ * harness fact from the loop, through `appendTurn`, into a row.
32
+ */
33
+ export const HARNESS_PROVIDER_NAMESPACE = "cortex";
34
+ /** The {@link HARNESS_PROVIDER_NAMESPACE} key marking a message the loop synthesized rather than one a human sent. */
35
+ export const SYNTHETIC_MESSAGE_KEY = "synthetic";
36
+ /** The {@link HARNESS_PROVIDER_NAMESPACE} key marking an assistant message whose production a client abort cut off. */
37
+ export const INTERRUPTED_MESSAGE_KEY = "interrupted";
38
+ /**
39
+ * Return a copy of `message` carrying the interruption marker in the harness namespace.
40
+ *
41
+ * The marker lives in {@link HARNESS_PROVIDER_NAMESPACE} for the same reason the synthetic marker does:
42
+ * it is the only field of a `ModelMessage` that survives the loop → {@link envelopeMessage} → stored-row
43
+ * round trip, so a reader of stored rows can still tell an interrupted reply apart. It rides the
44
+ * **assistant** role, which is not a turn boundary, so no boundary reader (`isGenuineUserStart`, the
45
+ * tail-retract predicate, window snapping) can observe it. Any existing `providerOptions` namespace is
46
+ * preserved — a signed thinking signature or cache directive on the same message must not be dropped — so
47
+ * this merges into the harness namespace rather than replacing the whole `providerOptions`.
48
+ */
49
+ export function markInterruptedMessage(message) {
50
+ const existingOptions = message.providerOptions ?? {};
51
+ const existingHarness = existingOptions[HARNESS_PROVIDER_NAMESPACE] ?? {};
52
+ return {
53
+ ...message,
54
+ providerOptions: {
55
+ ...existingOptions,
56
+ [HARNESS_PROVIDER_NAMESPACE]: { ...existingHarness, [INTERRUPTED_MESSAGE_KEY]: true },
57
+ },
58
+ };
59
+ }
60
+ /** Whether a client abort cut off this message's production (see {@link markInterruptedMessage}); an absent key reads as not interrupted. */
61
+ export function isInterruptedMessage(message) {
62
+ return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[INTERRUPTED_MESSAGE_KEY] === true;
63
+ }
64
+ /**
65
+ * Build a `user` message the agent loop synthesized — today, the nudge that continues a reply the model
66
+ * truncated at its output-token limit.
67
+ *
68
+ * It has to carry the `user` role because the wire format requires a user turn after a truncated
69
+ * assistant message, but it is not user input, and the difference is load-bearing: a `user` message is
70
+ * what OPENS a conversation turn, so an unmarked synthetic one reads as a spurious turn boundary —
71
+ * splitting one turn into two for the token window and, worse, giving a tail-turn removal a cut point in
72
+ * the middle of a turn. Marking it is what lets {@link isSyntheticUserMessage} keep those readers honest.
73
+ */
74
+ export function syntheticUserMessage(text) {
75
+ return {
76
+ role: "user",
77
+ content: text,
78
+ providerOptions: { [HARNESS_PROVIDER_NAMESPACE]: { [SYNTHETIC_MESSAGE_KEY]: true } },
79
+ };
80
+ }
81
+ /** Whether the loop synthesized this message (see {@link syntheticUserMessage}) rather than a human sending it. */
82
+ export function isSyntheticUserMessage(message) {
83
+ return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[SYNTHETIC_MESSAGE_KEY] === true;
84
+ }
23
85
  function providerOptionsFromLegacy(block) {
24
86
  // A block may carry both a signed thinking signature and cache_control;
25
87
  // both are required for valid continuation, so both must survive.
@@ -25,9 +25,11 @@ import type { ToolCardResolver } from "./reconstruct-cards.js";
25
25
  import type { StoredMessage } from "./thread-history.js";
26
26
  /**
27
27
  * Map stored messages (oldest-first) to `CortexMessage[]`. Rows that produce
28
- * no renderable parts are dropped, and consecutive same-role rows are coalesced
28
+ * no renderable parts are dropped, and consecutive assistant rows are coalesced
29
29
  * into one message. The first row's `seq` becomes the stable message id.
30
30
  * `resolveCard` (optional) reconstructs display cards from `tool_use` blocks.
31
+ * A row carrying the interruption marker sets `interrupted: true` on the message
32
+ * it lands in, including when coalesced into an assistant run.
31
33
  *
32
34
  * Coalescing mirrors the live SSE shape. The agent loop persists each assistant
33
35
  * step (typically a single `tool_use`) as its own row, and the `tool_result`
@@ -38,5 +40,10 @@ import type { StoredMessage } from "./thread-history.js";
38
40
  * tool call, which the UI renders as a stack of single-tool boxes. Merging the
39
41
  * run restores the one-bubble-per-turn shape (and lets the UI collapse the tool
40
42
  * calls into one group) regardless of how the loop split the turn into rows.
43
+ *
44
+ * Coalescing is assistant-only. User rows never legitimately split into a run —
45
+ * adjacent `user` rows arise only from turns that persisted no reply (an aborted
46
+ * turn's lone user message followed by the next turn's), and merging them would
47
+ * fabricate one bubble from two messages the user sent separately.
41
48
  */
42
49
  export declare function contentToCortexMessages(messages: readonly StoredMessage[], resolveCard?: ToolCardResolver): Promise<CortexMessage[]>;
@@ -20,6 +20,7 @@
20
20
  * only tool-result continuation parts) is omitted entirely, so the rendered
21
21
  * conversation has no empty bubbles. Storage is never mutated.
22
22
  */
23
+ import { isInterruptedMessage, isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
23
24
  function genericToolCall(block) {
24
25
  return {
25
26
  type: "tool-call",
@@ -53,9 +54,11 @@ async function rowToParts(message, resolveCard) {
53
54
  }
54
55
  /**
55
56
  * Map stored messages (oldest-first) to `CortexMessage[]`. Rows that produce
56
- * no renderable parts are dropped, and consecutive same-role rows are coalesced
57
+ * no renderable parts are dropped, and consecutive assistant rows are coalesced
57
58
  * into one message. The first row's `seq` becomes the stable message id.
58
59
  * `resolveCard` (optional) reconstructs display cards from `tool_use` blocks.
60
+ * A row carrying the interruption marker sets `interrupted: true` on the message
61
+ * it lands in, including when coalesced into an assistant run.
59
62
  *
60
63
  * Coalescing mirrors the live SSE shape. The agent loop persists each assistant
61
64
  * step (typically a single `tool_use`) as its own row, and the `tool_result`
@@ -66,20 +69,49 @@ async function rowToParts(message, resolveCard) {
66
69
  * tool call, which the UI renders as a stack of single-tool boxes. Merging the
67
70
  * run restores the one-bubble-per-turn shape (and lets the UI collapse the tool
68
71
  * calls into one group) regardless of how the loop split the turn into rows.
72
+ *
73
+ * Coalescing is assistant-only. User rows never legitimately split into a run —
74
+ * adjacent `user` rows arise only from turns that persisted no reply (an aborted
75
+ * turn's lone user message followed by the next turn's), and merging them would
76
+ * fabricate one bubble from two messages the user sent separately.
69
77
  */
70
78
  export async function contentToCortexMessages(messages, resolveCard) {
71
79
  const out = [];
72
80
  for (const message of messages) {
81
+ // The loop's truncation nudge carries the `user` role only for the wire
82
+ // format; rendering it would show words the user never typed, so drop it.
83
+ // Skipping first is safe: a synthetic user message can never carry the
84
+ // interruption marker — that rides assistant rows — so this cannot interact
85
+ // with the marker-fold logic below. The assistant rows it separated then
86
+ // become adjacent and coalesce into one bubble, which is the shape we want.
87
+ if (isSyntheticUserMessage(message.message))
88
+ continue;
73
89
  const parts = await rowToParts(message.message, resolveCard);
74
- if (parts.length === 0)
90
+ // The marker is read before the zero-parts drop so the flag can never be lost to it:
91
+ // a marked row carries an interruption fact regardless of whether it renders any parts.
92
+ // When such a row contributes no bubble of its own, the interruption belongs to the
93
+ // assistant run it trailed, so fold the flag onto the previous emitted assistant.
94
+ const interrupted = isInterruptedMessage(message.message);
95
+ if (parts.length === 0) {
96
+ if (interrupted) {
97
+ const prev = out[out.length - 1];
98
+ if (prev && prev.role === "assistant")
99
+ prev.interrupted = true;
100
+ }
75
101
  continue;
102
+ }
76
103
  const role = message.message.role === "tool" ? "assistant" : message.message.role;
77
104
  const prev = out[out.length - 1];
78
- if (prev && prev.role === role) {
105
+ if (prev && prev.role === role && role === "assistant") {
79
106
  prev.parts.push(...parts);
107
+ if (interrupted)
108
+ prev.interrupted = true;
80
109
  continue;
81
110
  }
82
- out.push({ id: String(message.seq), role, parts });
111
+ const cortex = { id: String(message.seq), role, parts };
112
+ if (interrupted)
113
+ cortex.interrupted = true;
114
+ out.push(cortex);
83
115
  }
84
116
  return out;
85
117
  }
@@ -43,6 +43,21 @@ export interface MessagePage {
43
43
  readonly perPage: number;
44
44
  readonly hasMore: boolean;
45
45
  }
46
+ /**
47
+ * The result of `retractLastTurn`. `retracted` carries `messages` — the number
48
+ * of rows removed — so a caller can assert exactly what came off the tail. The
49
+ * other two variants delete nothing and are distinct on purpose: `empty-thread`
50
+ * had no rows at all, while `no-user-turn` had rows but none opening a turn —
51
+ * anomalous data refused rather than silently emptied.
52
+ */
53
+ export type RetractOutcome = {
54
+ kind: "retracted";
55
+ messages: number;
56
+ } | {
57
+ kind: "empty-thread";
58
+ } | {
59
+ kind: "no-user-turn";
60
+ };
46
61
  /**
47
62
  * The conversation message store. Two methods, by design — no generic row
48
63
  * insert (see the harness-thread-store spec). `threadId` is the conversation scope — one UI thread.
@@ -69,6 +84,28 @@ export interface ThreadHistory {
69
84
  * holds the flattened rows of the selected turns.
70
85
  */
71
86
  loadPage(threadId: string, page: number, perPage: number): ResultAsync<MessagePage, DbError>;
87
+ /**
88
+ * Remove the thread's most recent turn — every row from the last
89
+ * genuine-user-start `seq` onward — in a single transaction.
90
+ *
91
+ * Tail-only by design: removal cuts at the last genuine-user-start `seq` —
92
+ * a turn boundary, not the last append. Because every conversation turn
93
+ * opens with the user's message, that boundary starts the most recent turn,
94
+ * so removing it restores the row set the thread held before that turn and
95
+ * leaves `loadRecent`'s byte-stable prompt-cache prefix untouched; an
96
+ * out-of-contract assistant-only follow-up append carries no user-start row
97
+ * and so folds into the removed tail, exactly as the read side's turn
98
+ * grouping folds it. Deleting mid-history would shift the window head and
99
+ * rewrite that prefix, so it is deliberately not offered — only the tail
100
+ * can come off.
101
+ *
102
+ * Callers are assumed single-writer per thread (the host serializes turns);
103
+ * the outcome's `messages` count lets a caller assert exactly what was
104
+ * removed. A thread that has rows but no genuine-user-start row is anomalous
105
+ * data — refused as `no-user-turn` with nothing deleted, never emptied. An
106
+ * empty thread reports `empty-thread`.
107
+ */
108
+ retractLastTurn(threadId: string): ResultAsync<RetractOutcome, DbError>;
72
109
  }
73
110
  /**
74
111
  * Drop the memoized instruments so the next `loadRecent` rebinds to a
@@ -21,22 +21,46 @@
21
21
  * rounds the budget walk to turn boundaries.
22
22
  */
23
23
  import { metrics } from "@opentelemetry/api";
24
- import { ResultAsync, ok } from "neverthrow";
24
+ import { ResultAsync, ok, okAsync } from "neverthrow";
25
25
  import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
26
26
  import { countTokens } from "./count-tokens.js";
27
- import { envelopeMessage, parseStoredMessageEnvelope } from "./ai-sdk-message-storage.js";
27
+ import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, isSyntheticUserMessage, parseStoredMessageEnvelope, } from "./ai-sdk-message-storage.js";
28
28
  /** A resolved `ok(undefined)` ResultAsync — the empty/seed transaction step. */
29
29
  function okVoid() {
30
30
  return new ResultAsync(Promise.resolve(ok(undefined)));
31
31
  }
32
32
  /**
33
- * A turn starts on a `user` message. In AI SDK message terms tool results
34
- * are `tool`-role messages, so a mid-turn tool continuation never carries
35
- * the `user` role and cannot open a turn.
33
+ * A turn starts on a `user` message that a human actually sent. Two kinds of
34
+ * message carry the `user` role without opening a turn, and both are excluded
35
+ * here:
36
+ *
37
+ * - a tool result, which in AI SDK terms is a `tool`-role message, so a mid-turn
38
+ * tool continuation never matches in the first place;
39
+ * - a message the LOOP synthesized mid-turn — the truncated-reply nudge
40
+ * (`syntheticUserMessage`) — which must carry the `user` role for the wire
41
+ * format but is not user input. Reading it as a boundary would split one turn
42
+ * into two: the token window would evict half a turn, and `retractLastTurn`
43
+ * would cut its tail in the middle of a turn rather than at its head.
44
+ *
45
+ * {@link GENUINE_USER_START_SQL} is the twin of this predicate over stored
46
+ * envelopes; the two are built from the same constants so they cannot drift.
36
47
  */
37
48
  function isGenuineUserStart(message) {
38
- return message.role === "user";
49
+ return message.role === "user" && !isSyntheticUserMessage(message);
39
50
  }
51
+ /**
52
+ * {@link isGenuineUserStart} expressed over a stored `message_envelope` — the
53
+ * boundary predicate `retractLastTurn` cuts on.
54
+ *
55
+ * Interpolating here, and only here, is safe: both interpolated values are
56
+ * module constants shared with the TypeScript predicate, never caller input, so
57
+ * there is no injection surface — and a bound parameter cannot express a JSON
58
+ * path anyway. `IS DISTINCT FROM` (not `<> 'true'`) because `->>` yields NULL on
59
+ * every message that carries no `providerOptions` at all, which is nearly all of
60
+ * them; a plain inequality would discard them.
61
+ */
62
+ const GENUINE_USER_START_SQL = `message_envelope->'message'->>'role' = 'user'
63
+ AND message_envelope->'message'->'providerOptions'->'${HARNESS_PROVIDER_NAMESPACE}'->>'${SYNTHETIC_MESSAGE_KEY}' IS DISTINCT FROM 'true'`;
40
64
  /**
41
65
  * Group rows (oldest-first) into turns at genuine-user-start boundaries.
42
66
  * Generic over the row shape so the token-windowed read (`loadRecent`) and
@@ -223,5 +247,48 @@ export function createThreadHistory(pool) {
223
247
  };
224
248
  });
225
249
  }
226
- return { appendTurn, loadRecent, loadPage };
250
+ function retractLastTurn(threadId) {
251
+ return withTransaction(pool, "thread-history.retractLastTurn", (client) =>
252
+ // Take the SAME per-thread advisory lock `appendTurn` takes, and take it
253
+ // FIRST — a retract and an append on one thread must never interleave, or
254
+ // the retract could read a boundary mid-append and delete only part of a
255
+ // turn still being written. Serialized on this lock it always sees a whole
256
+ // turn or none of one. Released automatically at COMMIT/ROLLBACK.
257
+ tryQuery("thread-history.retractLastTurn.lock", () => client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [threadId]))
258
+ .andThen(() =>
259
+ // Locate the tail turn's opening row and, in the same read, learn
260
+ // whether the thread has any rows at all. The boundary is the greatest
261
+ // `seq` matching `GENUINE_USER_START_SQL` — the stored-envelope twin of
262
+ // `isGenuineUserStart`, so a `tool`-role continuation and a
263
+ // loop-synthesized nudge are both excluded and the cut lands on a real
264
+ // turn head. The coupling is test-guarded: an append-then-retract
265
+ // round-trip fails loudly (the just-appended turn reads back as
266
+ // `no-user-turn` instead of `retracted`) the moment this envelope path
267
+ // drifts from what `isGenuineUserStart` reads on the live message.
268
+ // `has_rows` separates the two nothing-to-delete cases: an empty thread
269
+ // (`empty-thread`) from one holding no turn-opening row at all
270
+ // (`no-user-turn`, anomalous data we refuse rather than empty).
271
+ // `MAX(seq)` aggregates the bigint column; the `::text` cast is
272
+ // transport only, so no comparison ever runs against a text projection —
273
+ // and the boundary rides back as text so a seq beyond 2^53 survives
274
+ // without float rounding.
275
+ tryQuery("thread-history.retractLastTurn.boundary", async () => {
276
+ const { rows } = await client.query(`SELECT EXISTS(SELECT 1 FROM messages WHERE thread_id = $1) AS has_rows,
277
+ (SELECT MAX(seq)::text FROM messages
278
+ WHERE thread_id = $1
279
+ AND ${GENUINE_USER_START_SQL}) AS boundary`, [threadId]);
280
+ return rows[0];
281
+ }))
282
+ .andThen(({ has_rows, boundary }) => {
283
+ if (!has_rows)
284
+ return okAsync({ kind: "empty-thread" });
285
+ if (boundary === null)
286
+ return okAsync({ kind: "no-user-turn" });
287
+ // Delete the whole tail turn: every row at or past the boundary seq.
288
+ // `$2::bigint` compares the bigint column against a bigint, never a
289
+ // text projection, keeping the comparison exact for large seqs.
290
+ return tryMutation("thread-history.retractLastTurn.delete", () => client.query("DELETE FROM messages WHERE thread_id = $1 AND seq >= $2::bigint", [threadId, boundary])).map((res) => ({ kind: "retracted", messages: res.rowCount ?? 0 }));
291
+ }));
292
+ }
293
+ return { appendTurn, loadRecent, loadPage, retractLastTurn };
227
294
  }