@inflexa-ai/harness 0.13.0 → 0.14.1

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 (162) hide show
  1. package/dist/agents/conversation-agent.js +23 -16
  2. package/dist/agents/sandbox/cheminformatics-agent.js +1 -1
  3. package/dist/agents/sandbox/chromatin-agent.js +1 -1
  4. package/dist/agents/sandbox/dna-methylation-agent.js +1 -1
  5. package/dist/agents/sandbox/drug-repurposing-agent.js +5 -10
  6. package/dist/agents/sandbox/enrichment-agent.js +1 -1
  7. package/dist/agents/sandbox/genomic-variant-agent.js +1 -11
  8. package/dist/agents/sandbox/immune-profiling-agent.js +1 -1
  9. package/dist/agents/sandbox/multi-omics-integration-agent.js +1 -1
  10. package/dist/agents/sandbox/network-agent.js +1 -1
  11. package/dist/agents/sandbox/pkpd-clinical-response-agent.js +3 -6
  12. package/dist/agents/sandbox/scientific-executor.js +1 -1
  13. package/dist/agents/sandbox/shared.js +7 -13
  14. package/dist/agents/sandbox/translational-safety-agent.js +4 -7
  15. package/dist/agents/sandbox/types.d.ts +5 -5
  16. package/dist/agents/sandbox/types.js +4 -4
  17. package/dist/app/chat-turn.js +4 -1
  18. package/dist/app/message-assembly.d.ts +5 -2
  19. package/dist/app/message-assembly.js +7 -2
  20. package/dist/app/run-activity.d.ts +13 -0
  21. package/dist/app/run-activity.js +51 -0
  22. package/dist/contracts/target-dossier.d.ts +10 -10
  23. package/dist/execution/report-runner.js +1 -1
  24. package/dist/execution/run-event-parts.d.ts +44 -0
  25. package/dist/execution/run-event-parts.js +0 -0
  26. package/dist/execution/run-event-stream.d.ts +91 -0
  27. package/dist/execution/run-event-stream.js +212 -0
  28. package/dist/execution/run-synthesis.js +1 -1
  29. package/dist/index.d.ts +5 -2
  30. package/dist/index.js +20 -1
  31. package/dist/input-sanitization.d.ts +14 -4
  32. package/dist/input-sanitization.js +27 -4
  33. package/dist/lib/storage.js +4 -1
  34. package/dist/loop/budget-exceeded.d.ts +18 -2
  35. package/dist/loop/budget-exceeded.js +49 -2
  36. package/dist/loop/run-agent.d.ts +22 -1
  37. package/dist/loop/run-agent.js +91 -25
  38. package/dist/loop/run-to-terminal.d.ts +22 -4
  39. package/dist/loop/run-to-terminal.js +20 -4
  40. package/dist/memory/ai-sdk-message-storage.d.ts +37 -4
  41. package/dist/memory/ai-sdk-message-storage.js +45 -4
  42. package/dist/memory/content-to-cortex.js +19 -3
  43. package/dist/memory/message-backfill.js +1 -1
  44. package/dist/memory/thread-history.js +34 -8
  45. package/dist/prompts/conversation.d.ts +1 -1
  46. package/dist/prompts/conversation.js +15 -6
  47. package/dist/prompts/literature-reviewer.d.ts +1 -1
  48. package/dist/prompts/literature-reviewer.js +16 -14
  49. package/dist/prompts/planner.js +15 -20
  50. package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
  51. package/dist/prompts/sandbox/drug-repurposing-agent.js +31 -22
  52. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  53. package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -10
  54. package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
  55. package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +8 -7
  56. package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
  57. package/dist/prompts/sandbox/translational-safety-agent.js +20 -14
  58. package/dist/prompts/synthesis-agent.d.ts +1 -1
  59. package/dist/prompts/synthesis-agent.js +7 -7
  60. package/dist/providers/errors.d.ts +7 -0
  61. package/dist/providers/errors.js +88 -3
  62. package/dist/runtime/pools.d.ts +15 -1
  63. package/dist/runtime/pools.js +15 -1
  64. package/dist/sandbox/sandbox-step-translate.d.ts +15 -2
  65. package/dist/sandbox/sandbox-step-translate.js +17 -2
  66. package/dist/schemas/workflow-state.d.ts +2 -2
  67. package/dist/schemas/workflow-state.js +2 -2
  68. package/dist/state/data-profile.d.ts +53 -1
  69. package/dist/state/data-profile.js +66 -6
  70. package/dist/state/index.d.ts +3 -3
  71. package/dist/state/index.js +2 -2
  72. package/dist/state/init.js +37 -2
  73. package/dist/state/runs.d.ts +39 -0
  74. package/dist/state/runs.js +103 -0
  75. package/dist/tasks/data-profile-activity.d.ts +87 -0
  76. package/dist/tasks/data-profile-activity.js +56 -0
  77. package/dist/tasks/data-profile.js +66 -4
  78. package/dist/tools/bio/chembl.d.ts +1 -1
  79. package/dist/tools/bio/chembl.js +51 -54
  80. package/dist/tools/bio/comptox.js +35 -33
  81. package/dist/tools/bio/drug-gene-interactions.d.ts +76 -0
  82. package/dist/tools/bio/drug-gene-interactions.js +175 -0
  83. package/dist/tools/bio/gene-disease-evidence.d.ts +61 -0
  84. package/dist/tools/bio/gene-disease-evidence.js +187 -0
  85. package/dist/tools/bio/gene-preclinical-profile.d.ts +58 -0
  86. package/dist/tools/bio/gene-preclinical-profile.js +125 -0
  87. package/dist/tools/bio/index.d.ts +23 -16
  88. package/dist/tools/bio/index.js +29 -22
  89. package/dist/tools/bio/keys.d.ts +16 -9
  90. package/dist/tools/bio/keys.js +22 -13
  91. package/dist/tools/bio/lookup-annotation.d.ts +48 -0
  92. package/dist/tools/bio/lookup-annotation.js +173 -0
  93. package/dist/tools/bio/opentargets.d.ts +7 -11
  94. package/dist/tools/bio/opentargets.js +24 -35
  95. package/dist/tools/bio/pubchem.js +6 -4
  96. package/dist/tools/bio/pubmed.d.ts +15 -7
  97. package/dist/tools/bio/pubmed.js +79 -20
  98. package/dist/tools/bio/search-clinical-trials.d.ts +20 -1
  99. package/dist/tools/bio/search-clinical-trials.js +26 -5
  100. package/dist/tools/bio/search-faers.js +9 -3
  101. package/dist/tools/bio/search-gene.d.ts +0 -1
  102. package/dist/tools/bio/search-gene.js +4 -5
  103. package/dist/tools/bio/search-geo-datasets.js +10 -4
  104. package/dist/tools/bio/search-interactions.d.ts +8 -4
  105. package/dist/tools/bio/search-interactions.js +28 -12
  106. package/dist/tools/bio/target-safety.d.ts +59 -0
  107. package/dist/tools/bio/target-safety.js +191 -0
  108. package/dist/tools/execute-analysis.d.ts +1 -0
  109. package/dist/tools/execute-analysis.js +5 -5
  110. package/dist/tools/lib/dgidb-client.d.ts +49 -0
  111. package/dist/tools/lib/dgidb-client.js +168 -0
  112. package/dist/tools/lib/disgenet-client.d.ts +56 -0
  113. package/dist/tools/lib/disgenet-client.js +59 -0
  114. package/dist/tools/lib/drugbank-client.d.ts +83 -0
  115. package/dist/tools/lib/drugbank-client.js +95 -0
  116. package/dist/tools/lib/ensembl-client.d.ts +4 -1
  117. package/dist/tools/lib/ensembl-client.js +5 -3
  118. package/dist/tools/lib/gwas-catalog-client.d.ts +60 -0
  119. package/dist/tools/lib/gwas-catalog-client.js +136 -0
  120. package/dist/tools/lib/pharmgkb-client.d.ts +22 -0
  121. package/dist/tools/lib/pharmgkb-client.js +32 -0
  122. package/dist/tools/lib/pubmed-client.d.ts +57 -4
  123. package/dist/tools/lib/pubmed-client.js +57 -3
  124. package/dist/tools/lib/string-client.d.ts +25 -4
  125. package/dist/tools/lib/string-client.js +22 -5
  126. package/dist/tools/research/generate-plan.d.ts +16 -9
  127. package/dist/tools/research/generate-plan.js +405 -85
  128. package/dist/tools/research/inspect-data-profile.d.ts +31 -6
  129. package/dist/tools/research/inspect-data-profile.js +23 -7
  130. package/dist/tools/research/inspect-run.d.ts +33 -15
  131. package/dist/tools/research/inspect-run.js +155 -66
  132. package/dist/tools/research/literature-reviewer.js +7 -11
  133. package/dist/workflows/execute-analysis.d.ts +63 -0
  134. package/dist/workflows/execute-analysis.js +73 -2
  135. package/dist/workflows/sandbox-step.js +1 -11
  136. package/dist/workflows/target-assessment/decisions/index.d.ts +1 -1
  137. package/dist/workflows/target-assessment/phase4-assemble.d.ts +1 -1
  138. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +2 -2
  139. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -1
  140. package/package.json +1 -1
  141. package/dist/tools/bio/check-safety-panel.d.ts +0 -35
  142. package/dist/tools/bio/check-safety-panel.js +0 -76
  143. package/dist/tools/bio/get-impc-ko-profile.d.ts +0 -9
  144. package/dist/tools/bio/get-impc-ko-profile.js +0 -23
  145. package/dist/tools/bio/lookup-go-term.d.ts +0 -31
  146. package/dist/tools/bio/lookup-go-term.js +0 -116
  147. package/dist/tools/bio/search-bgee-expression.d.ts +0 -10
  148. package/dist/tools/bio/search-bgee-expression.js +0 -30
  149. package/dist/tools/bio/search-clinvar.d.ts +0 -13
  150. package/dist/tools/bio/search-clinvar.js +0 -28
  151. package/dist/tools/bio/search-dgidb.d.ts +0 -53
  152. package/dist/tools/bio/search-dgidb.js +0 -213
  153. package/dist/tools/bio/search-disgenet.d.ts +0 -30
  154. package/dist/tools/bio/search-disgenet.js +0 -84
  155. package/dist/tools/bio/search-drugbank.d.ts +0 -38
  156. package/dist/tools/bio/search-drugbank.js +0 -109
  157. package/dist/tools/bio/search-gwas-catalog.d.ts +0 -33
  158. package/dist/tools/bio/search-gwas-catalog.js +0 -172
  159. package/dist/tools/bio/search-pathway.d.ts +0 -12
  160. package/dist/tools/bio/search-pathway.js +0 -32
  161. package/dist/tools/bio/search-pharmgkb.d.ts +0 -17
  162. package/dist/tools/bio/search-pharmgkb.js +0 -52
@@ -1,6 +1,7 @@
1
1
  import { type FinishReason } from "ai";
2
2
  import type { AgentSession } from "../auth/types.js";
3
- import type { AgentChat, PromptCachePolicy } from "../providers/types.js";
3
+ import type { Logger } from "../lib/logger.js";
4
+ import type { AgentChat, ChatRequest, PromptCachePolicy } from "../providers/types.js";
4
5
  import { type AskApproval, type AskRequest } from "../tools/approval/contract.js";
5
6
  import type { AgentDefinition, EmitFn, LoopMessage, RunStep } from "./types.js";
6
7
  export interface AgentFinish {
@@ -34,6 +35,15 @@ export interface RunAgentOptions {
34
35
  readonly runStep: RunStep;
35
36
  readonly formatStepName?: StepNameFormatter;
36
37
  readonly isFatalLoopError?: (err: unknown) => boolean;
38
+ /** Tool-selection policy for in-loop model calls. The cap wrap-up remains
39
+ * tool-less regardless. */
40
+ readonly toolChoice?: ChatRequest["toolChoice"];
41
+ /**
42
+ * Optional outcome predicate for loop-driving agents whose result is
43
+ * recorded by a tool into closure state. Checked immediately after each
44
+ * dispatch round, once every sibling tool result has been appended.
45
+ */
46
+ readonly resolved?: () => boolean;
37
47
  /**
38
48
  * Prompt-cache policy for every LLM call this run makes. Defaults to
39
49
  * `DEFAULT_PROMPT_CACHE` (5m) — an agent loop always re-sends its prefix, so
@@ -46,6 +56,17 @@ export interface RunAgentOptions {
46
56
  * cache-write premium for a cache nothing ever reads back.
47
57
  */
48
58
  readonly promptCache?: PromptCachePolicy;
59
+ /**
60
+ * Diagnostic sink for the run's own lifecycle. Optional because `runAgent` is
61
+ * called from tools, workflow bodies, and test rigs alike — silence is the
62
+ * correct behaviour for a caller that wires nothing, and the noop fallback
63
+ * gives it without every call site threading `?.`.
64
+ *
65
+ * Distinct from `emit`, which feeds a user-facing surface: every host filters
66
+ * sub-agent events off that surface by `callPath` depth, so a sub-agent's
67
+ * progress is emitted and then dropped. This is where it survives.
68
+ */
69
+ readonly logger?: Logger;
49
70
  }
50
71
  export declare function runAgent(agent: AgentDefinition, initial: readonly LoopMessage[], session: AgentSession, opts: RunAgentOptions): Promise<RunAgentResult>;
51
72
  export declare function finalText(messages: readonly LoopMessage[]): string;
@@ -1,4 +1,6 @@
1
1
  import { jsonSchema, tool as aiTool } from "ai";
2
+ import { stripNulCharacters } from "../input-sanitization.js";
3
+ import { createNoopLogger } from "../lib/console-logger.js";
2
4
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
3
5
  import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
4
6
  import { classifyProviderError } from "../providers/errors.js";
@@ -16,7 +18,11 @@ export const DEFAULT_STEP_NAME_FORMATTER = {
16
18
  export async function runAgent(agent, initial, session, opts) {
17
19
  const { provider, signal, emit, runStep } = opts;
18
20
  const formatStepName = opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER;
19
- const isFatalLoopError = opts.isFatalLoopError ?? (() => false);
21
+ const configuredFatalLoopError = opts.isFatalLoopError ?? (() => false);
22
+ // AbortError is control flow, not a tool failure. Always compose it with the
23
+ // host's fatal predicate so request cancellation cannot be converted into a
24
+ // model-visible error result and retried by the loop.
25
+ const isFatalLoopError = (err) => ((err instanceof Error || err instanceof DOMException) && err.name === "AbortError") || configuredFatalLoopError(err);
20
26
  if (agent.tools.length > 0 && !provider.capabilities.toolCalling) {
21
27
  throw new Error(`Provider/model cannot run tool-required agent "${agent.id}"`);
22
28
  }
@@ -25,6 +31,11 @@ export async function runAgent(agent, initial, session, opts) {
25
31
  agentId: session.provenance.agentId ?? agent.id,
26
32
  callPath: session.provenance.callPath,
27
33
  };
34
+ // Bound from the same `source` the emitted events carry: one derivation feeding
35
+ // both sinks, so a record and an event cannot disagree about who produced them.
36
+ // `callPath` rides as an array rather than a joined string — the queryable form;
37
+ // rendering `parent > child` is the sink's choice, not a published package's.
38
+ const log = (opts.logger ?? createNoopLogger()).named("loop").with({ agentId: source.agentId, callPath: source.callPath });
28
39
  const toolsById = new Map(agent.tools.map((t) => [t.id, t]));
29
40
  const toolDefs = Object.fromEntries(agent.tools.map((t) => [
30
41
  t.id,
@@ -49,11 +60,23 @@ export async function runAgent(agent, initial, session, opts) {
49
60
  });
50
61
  let iterations = 0;
51
62
  let truncationRecoveries = 0;
63
+ // Counted rather than listed: the finish record stays one bounded line for a run of
64
+ // any length, and "50 iterations, 49 tool calls, 47 of them errored" already separates
65
+ // a productive long run from a loop stuck retrying one failing call. The per-iteration
66
+ // `debug` records name the tools when that distinction is not enough.
67
+ let toolCallCount = 0;
68
+ let toolErrorCount = 0;
52
69
  // Resolved once, not per iteration: an identical options object across every
53
70
  // call is itself part of the cache contract — the request prefix has to be
54
71
  // byte-identical to be read back.
55
72
  const providerOptions = promptCacheProviderOptions(opts.promptCache ?? DEFAULT_PROMPT_CACHE);
56
73
  const usage = {};
74
+ // Exactly one record per completed run — never one per iteration. That bound is
75
+ // what keeps the default level affordable for an agent that runs long; the
76
+ // per-iteration detail lives at `debug`, where paying per iteration is the point.
77
+ const logFinish = (level, reason, cappedOut) => {
78
+ log[level]("run finished", { iterations, reason, cappedOut, truncationRecoveries, toolCalls: toolCallCount, toolErrors: toolErrorCount, usage });
79
+ };
57
80
  // The user said no. A subsequent model call would only let the agent argue
58
81
  // with the decision, or spend a call acknowledging it; the denial tool result
59
82
  // is itself what the surface renders, so the turn ends the moment a denial
@@ -62,14 +85,44 @@ export async function runAgent(agent, initial, session, opts) {
62
85
  const stopOnDenial = async (i) => {
63
86
  await emit({ type: "iteration", source, index: i, final: true });
64
87
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
88
+ logFinish("warn", "denied", false);
65
89
  return { messages, finish: { reason: "denied", cappedOut: false, truncationRecoveries } };
66
90
  };
91
+ /**
92
+ * Fold one dispatch round into the run's counters and the event sink, returning the
93
+ * tools whose results the model will read as errors.
94
+ *
95
+ * Shared by both dispatch paths so a truncated round and a normal one cannot drift
96
+ * in what they count. An error tool result is otherwise invisible to every sink —
97
+ * it is not a thrown failure, so nothing reports it and the loop simply feeds it
98
+ * back — and a run that ends badly is usually a run whose tool calls were failing.
99
+ */
100
+ const settleRound = async (calls, results) => {
101
+ const errored = [];
102
+ for (let idx = 0; idx < calls.length; idx++) {
103
+ const tu = calls[idx];
104
+ const isError = isErrorOutput(results[idx]);
105
+ toolCallCount++;
106
+ if (isError) {
107
+ toolErrorCount++;
108
+ errored.push(tu.toolName);
109
+ }
110
+ await emit({ type: "tool-finished", source, toolUseId: tu.toolCallId, name: tu.toolName, isError });
111
+ }
112
+ return errored;
113
+ };
114
+ const stopOnResolved = async (i) => {
115
+ await emit({ type: "iteration", source, index: i, final: true });
116
+ recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
117
+ return { messages, finish: { reason: "stop", cappedOut: false, truncationRecoveries } };
118
+ };
67
119
  for (let i = 0; i < agent.maxIterations; i++) {
68
120
  iterations = i + 1;
69
121
  const request = {
70
122
  system: agent.systemPrompt,
71
123
  messages,
72
124
  tools: toolDefs,
125
+ ...(opts.toolChoice !== undefined ? { toolChoice: opts.toolChoice } : {}),
73
126
  providerOptions,
74
127
  };
75
128
  const reply = await resultStep(runStep)(formatStepName.llm(i), () => provider.chat(request, session, signal));
@@ -93,6 +146,9 @@ export async function runAgent(agent, initial, session, opts) {
93
146
  if (reply.finishReason === "length") {
94
147
  truncationRecoveries++;
95
148
  await emit({ type: "iteration", source, index: i, final: false });
149
+ // `tools` is what the model asked for; the trailing call was cut off at the
150
+ // output limit and is never dispatched, which the distinct message records.
151
+ log.debug("iteration truncated at output limit", { iteration: i, tools: toolCalls.map((t) => t.toolName), truncationRecoveries });
96
152
  if (toolCalls.length === 0) {
97
153
  // Stamped synthetic, not left as a bare `user` message: the wire format needs a user turn
98
154
  // after a truncated assistant message, but this one is the loop's own nudge, and thread
@@ -107,45 +163,43 @@ export async function runAgent(agent, initial, session, opts) {
107
163
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input });
108
164
  }
109
165
  const results = await dispatchTools(earlier, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool);
110
- for (let idx = 0; idx < earlier.length; idx++) {
111
- const tu = earlier[idx];
112
- await emit({
113
- type: "tool-finished",
114
- source,
115
- toolUseId: tu.toolCallId,
116
- name: tu.toolName,
117
- isError: isErrorOutput(results[idx]),
118
- });
119
- }
166
+ const errored = await settleRound(earlier, results);
120
167
  results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
168
+ // The trailing call was never dispatched, but it reaches the model as an error
169
+ // result like any other — so it counts, or `toolErrors` reports a cleaner run
170
+ // than the model actually read. It gets no `tool-finished` event: no
171
+ // `tool-started` was emitted for it either, and the pair must stay balanced.
172
+ toolCallCount++;
173
+ toolErrorCount++;
174
+ errored.push(trailing.toolName);
175
+ log.debug("tool results returned errors", { iteration: i, tools: errored });
121
176
  messages.push({ role: "tool", content: results });
122
177
  if (hasDenial(results))
123
178
  return stopOnDenial(i);
179
+ if (opts.resolved?.())
180
+ return stopOnResolved(i);
124
181
  continue;
125
182
  }
126
183
  if (reply.finishReason !== "tool-calls") {
127
184
  await emit({ type: "iteration", source, index: i, final: true });
128
185
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
186
+ logFinish("info", reply.finishReason, false);
129
187
  return { messages, finish: { reason: reply.finishReason, cappedOut: false, truncationRecoveries } };
130
188
  }
131
189
  await emit({ type: "iteration", source, index: i, final: false });
190
+ log.debug("iteration", { iteration: i, tools: toolCalls.map((t) => t.toolName) });
132
191
  for (const tu of toolCalls) {
133
192
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input });
134
193
  }
135
194
  const results = await dispatchTools(toolCalls, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool);
136
- for (let idx = 0; idx < toolCalls.length; idx++) {
137
- const tu = toolCalls[idx];
138
- await emit({
139
- type: "tool-finished",
140
- source,
141
- toolUseId: tu.toolCallId,
142
- name: tu.toolName,
143
- isError: isErrorOutput(results[idx]),
144
- });
145
- }
195
+ const errored = await settleRound(toolCalls, results);
196
+ if (errored.length > 0)
197
+ log.debug("tool results returned errors", { iteration: i, tools: errored });
146
198
  messages.push({ role: "tool", content: results });
147
199
  if (hasDenial(results))
148
200
  return stopOnDenial(i);
201
+ if (opts.resolved?.())
202
+ return stopOnResolved(i);
149
203
  }
150
204
  // Cache defeater (known; not fixed here). Emptying the tool set changes the
151
205
  // very front of the request prefix — tool definitions are cached ahead of
@@ -167,11 +221,13 @@ export async function runAgent(agent, initial, session, opts) {
167
221
  markLastLoopAssistant(messages, initial.length);
168
222
  await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
169
223
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
224
+ logFinish("warn", "aborted", true);
170
225
  return { messages, finish: { reason: "aborted", cappedOut: true, truncationRecoveries } };
171
226
  }
172
227
  messages.push(wrapUp.message);
173
228
  await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
174
229
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
230
+ logFinish("warn", "max_iterations", true);
175
231
  return { messages, finish: { reason: "max_iterations", cappedOut: true, truncationRecoveries } };
176
232
  }
177
233
  function toolCallParts(message) {
@@ -257,8 +313,14 @@ async function execute(tu, tool, input, ctx, isFatalLoopError) {
257
313
  return errorResult(tu, toolErrorContent(err));
258
314
  }
259
315
  }
316
+ /**
317
+ * Normalize a tool's return value to plain JSON, stripping NUL from every string
318
+ * it holds ({@link stripNulCharacters}). The reviver sees values, not keys — a
319
+ * NUL in an object KEY would still reach the row, but keys come from tool result
320
+ * types rather than scanned bytes, so nothing produces one.
321
+ */
260
322
  function jsonValue(value) {
261
- return JSON.parse(JSON.stringify(value ?? null));
323
+ return JSON.parse(JSON.stringify(value ?? null), (_key, v) => (typeof v === "string" ? stripNulCharacters(v) : v));
262
324
  }
263
325
  function successResult(toolCall, value) {
264
326
  return {
@@ -268,12 +330,15 @@ function successResult(toolCall, value) {
268
330
  output: { type: "json", value: jsonValue(value) },
269
331
  };
270
332
  }
333
+ // NUL is stripped from the prose BEFORE it is serialized: past the stringify it
334
+ // is the six-character escape \u0000 — legitimate JSON text, and no longer
335
+ // distinguishable from an error that happens to quote that escape.
271
336
  function toolErrorContent(value) {
272
337
  if (isToolError(value)) {
273
- return JSON.stringify({ error: value.error, retryable: value.retryable });
338
+ return JSON.stringify({ error: stripNulCharacters(value.error), retryable: value.retryable });
274
339
  }
275
340
  const { retryable } = classifyProviderError(value);
276
- const error = value instanceof Error ? value.message : String(value);
341
+ const error = stripNulCharacters(value instanceof Error ? value.message : String(value));
277
342
  return JSON.stringify({ error, retryable });
278
343
  }
279
344
  function errorResult(toolCall, content) {
@@ -281,7 +346,8 @@ function errorResult(toolCall, content) {
281
346
  type: "tool-result",
282
347
  toolCallId: toolCall.toolCallId,
283
348
  toolName: toolCall.toolName,
284
- output: { type: "error-text", value: content },
349
+ // A thrown tool error routinely quotes stderr verbatim — as exposed to NUL as a success payload.
350
+ output: { type: "error-text", value: stripNulCharacters(content) },
285
351
  };
286
352
  }
287
353
  function isAskRejected(err) {
@@ -22,14 +22,32 @@
22
22
  */
23
23
  import type { AgentSession } from "../auth/types.js";
24
24
  import type { Tool } from "../tools/define-tool.js";
25
- import { type RunAgentOptions, type RunAgentResult } from "./run-agent.js";
25
+ import { type AgentFinish, type RunAgentOptions, type RunAgentResult } from "./run-agent.js";
26
26
  import type { AgentDefinition, LoopMessage } from "./types.js";
27
27
  /** Default salvage budget: one submit plus a validation-fix retry or two. */
28
28
  export declare const DEFAULT_SALVAGE_ITERATIONS = 3;
29
+ /**
30
+ * What a salvage continuation was and how it went — present only when one ran.
31
+ *
32
+ * The two finishes answer different questions and a diagnostician needs both: a
33
+ * first run that ended `max_iterations` spent its whole budget failing to submit,
34
+ * while one that ended `stop` gave up on prose after a single turn. Those call for
35
+ * opposite responses, and the returned `RunAgentResult` carries only the second
36
+ * run's finish, which is the same for either.
37
+ */
38
+ export interface SalvageRecord {
39
+ /** How the FIRST run ended — the reason a salvage was needed at all. */
40
+ readonly firstFinish: AgentFinish;
41
+ /** How the salvage continuation itself ended. */
42
+ readonly finish: AgentFinish;
43
+ }
44
+ /** A `runToTerminal` outcome: the driving run's result, plus the salvage account. */
45
+ export interface RunToTerminalResult extends RunAgentResult {
46
+ /** Null when the first run reached its terminal tool and no salvage was needed. */
47
+ readonly salvage: SalvageRecord | null;
48
+ }
29
49
  /** Describes how to salvage a run that never reached its terminal tool. */
30
50
  export interface TerminalSalvage {
31
- /** True once the agent recorded its terminal outcome (reads the closure cell). */
32
- readonly resolved: () => boolean;
33
51
  /** Terminal tools offered on the salvage turn (submit / blocker / …). Must be
34
52
  * the same instances the first run used — they close over the outcome cell. */
35
53
  readonly tools: readonly Tool[];
@@ -43,4 +61,4 @@ export interface TerminalSalvage {
43
61
  * Returns the salvage run's result when a salvage occurred (its message array
44
62
  * already includes the first run's), otherwise the first run's result.
45
63
  */
46
- export declare function runToTerminal(agent: AgentDefinition, initial: readonly LoopMessage[], session: AgentSession, opts: RunAgentOptions, salvage: TerminalSalvage): Promise<RunAgentResult>;
64
+ export declare function runToTerminal(agent: AgentDefinition, initial: readonly LoopMessage[], session: AgentSession, opts: RunAgentOptions, salvage: TerminalSalvage): Promise<RunToTerminalResult>;
@@ -20,6 +20,7 @@
20
20
  * not collide the continuation's `llm-*` / `tool-*` cache keys with the first
21
21
  * run's — replaying them would silently return the first run's cached results.
22
22
  */
23
+ import { createNoopLogger } from "../lib/console-logger.js";
23
24
  import { DEFAULT_STEP_NAME_FORMATTER, runAgent } from "./run-agent.js";
24
25
  /** Default salvage budget: one submit plus a validation-fix retry or two. */
25
26
  export const DEFAULT_SALVAGE_ITERATIONS = 3;
@@ -36,16 +37,31 @@ function salvageStepNames(base) {
36
37
  */
37
38
  export async function runToTerminal(agent, initial, session, opts, salvage) {
38
39
  const first = await runAgent(agent, initial, session, opts);
39
- if (salvage.resolved() || opts.signal.aborted)
40
- return first;
40
+ if (opts.resolved?.() || opts.signal.aborted)
41
+ return { ...first, salvage: null };
42
+ const salvageBudget = salvage.maxIterations ?? DEFAULT_SALVAGE_ITERATIONS;
43
+ // Reported here rather than in `runAgent` because the loop cannot know it is being
44
+ // salvaged: it sees an ordinary run with a small budget and a restricted tool set.
45
+ // Only this wrapper holds the fact that a first attempt ended without its outcome.
46
+ // The first run's finish rides along because it is the whole diagnosis of WHY a
47
+ // salvage was needed, and it is the field the second run's result overwrites.
48
+ (opts.logger ?? createNoopLogger()).named("loop").warn("salvaging a run that never reached its terminal tool", {
49
+ agentId: agent.id,
50
+ callPath: session.provenance.callPath,
51
+ firstFinishReason: first.finish.reason,
52
+ firstCappedOut: first.finish.cappedOut,
53
+ salvageTools: salvage.tools.map((t) => t.id),
54
+ salvageMaxIterations: salvageBudget,
55
+ });
41
56
  const salvageAgent = {
42
57
  ...agent,
43
58
  tools: [...salvage.tools],
44
- maxIterations: salvage.maxIterations ?? DEFAULT_SALVAGE_ITERATIONS,
59
+ maxIterations: salvageBudget,
45
60
  };
46
61
  const salvageOpts = {
47
62
  ...opts,
48
63
  formatStepName: salvageStepNames(opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER),
49
64
  };
50
- return runAgent(salvageAgent, [...first.messages, { role: "user", content: salvage.nudge }], session, salvageOpts);
65
+ const salvaged = await runAgent(salvageAgent, [...first.messages, { role: "user", content: salvage.nudge }], session, salvageOpts);
66
+ return { ...salvaged, salvage: { firstFinish: first.finish, finish: salvaged.finish } };
51
67
  }
@@ -20,8 +20,19 @@ export declare function parseStoredMessageEnvelope(value: unknown, identity: str
20
20
  * harness fact from the loop, through `appendTurn`, into a row.
21
21
  */
22
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. */
23
+ /** The {@link HARNESS_PROVIDER_NAMESPACE} key marking a message the loop or host synthesized rather than one a human sent. */
24
24
  export declare const SYNTHETIC_MESSAGE_KEY = "synthetic";
25
+ /**
26
+ * The {@link HARNESS_PROVIDER_NAMESPACE} key marking a synthetic message that is a RECORD OF FACT
27
+ * the reader is meant to see, as opposed to loop machinery they are not.
28
+ *
29
+ * Always accompanies {@link SYNTHETIC_MESSAGE_KEY} — a record is synthetic in the sense that matters
30
+ * to every turn-boundary reader, and must never open a turn. This key answers the one question
31
+ * `synthetic` alone cannot: whether the message should be RENDERED. The loop's truncation nudge must
32
+ * not be (it would show words nobody typed); an embedder's out-of-band record must be (it is the
33
+ * only trace in the conversation that the work happened at all).
34
+ */
35
+ export declare const SYNTHETIC_RECORD_KEY = "syntheticRecord";
25
36
  /** The {@link HARNESS_PROVIDER_NAMESPACE} key marking an assistant message whose production a client abort cut off. */
26
37
  export declare const INTERRUPTED_MESSAGE_KEY = "interrupted";
27
38
  /**
@@ -39,18 +50,40 @@ export declare function markInterruptedMessage(message: ModelMessage): ModelMess
39
50
  /** Whether a client abort cut off this message's production (see {@link markInterruptedMessage}); an absent key reads as not interrupted. */
40
51
  export declare function isInterruptedMessage(message: ModelMessage): boolean;
41
52
  /**
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.
53
+ * Build a `user` message the LOOP synthesized rather than a human typing it — today, the nudge that
54
+ * continues a reply the model truncated at its output-token limit.
44
55
  *
45
56
  * It has to carry the `user` role because the wire format requires a user turn after a truncated
46
57
  * assistant message, but it is not user input, and the difference is load-bearing: a `user` message is
47
58
  * what OPENS a conversation turn, so an unmarked synthetic one reads as a spurious turn boundary —
48
59
  * splitting one turn into two for the token window and, worse, giving a tail-turn removal a cut point in
49
60
  * the middle of a turn. Marking it is what lets {@link isSyntheticUserMessage} keep those readers honest.
61
+ *
62
+ * This one is loop MACHINERY and is not rendered: showing it would put words on screen nobody typed.
63
+ * An embedder recording out-of-band work — an analysis run's outcome — wants
64
+ * {@link syntheticRecordMessage} instead, which is equally non-turn-opening but IS displayed.
50
65
  */
51
66
  export declare function syntheticUserMessage(text: string): ModelMessage;
52
- /** Whether the loop synthesized this message (see {@link syntheticUserMessage}) rather than a human sending it. */
67
+ /**
68
+ * Build a record of out-of-band work for an embedder to append between turns — an analysis run's
69
+ * outcome, which the model should read on its next turn but which nobody said.
70
+ *
71
+ * Synthetic in every sense that matters to a turn-boundary reader ({@link isSyntheticUserMessage} is
72
+ * true of it), so it cannot split a turn for the token window nor hand a tail retraction a mid-turn
73
+ * cut point. It differs from {@link syntheticUserMessage} in exactly one respect, and it is the
74
+ * decisive one: this is a fact the reader is entitled to see, so the display reconstruction emits it
75
+ * — as a `system` message, which is what it is — rather than dropping it as loop machinery.
76
+ *
77
+ * A host MUST build these here rather than assembling the `providerOptions` markers itself: the keys
78
+ * and namespace are shared with the turn-boundary and display predicates, and a hand-rolled copy
79
+ * would fork them silently — the message would store fine and then be read as a genuine turn start,
80
+ * or be dropped from the transcript with no indication why.
81
+ */
82
+ export declare function syntheticRecordMessage(text: string): ModelMessage;
83
+ /** Whether the loop or the host synthesized this message (see {@link syntheticUserMessage}) rather than a human sending it. */
53
84
  export declare function isSyntheticUserMessage(message: ModelMessage): boolean;
85
+ /** Whether this is a host-appended record of out-of-band work (see {@link syntheticRecordMessage}) — synthetic, but displayed. */
86
+ export declare function isSyntheticRecordMessage(message: ModelMessage): boolean;
54
87
  type LegacyContent = string | Array<Record<string, unknown>>;
55
88
  export interface LegacyMessageRow {
56
89
  readonly threadId: string;
@@ -31,8 +31,19 @@ export function parseStoredMessageEnvelope(value, identity) {
31
31
  * harness fact from the loop, through `appendTurn`, into a row.
32
32
  */
33
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. */
34
+ /** The {@link HARNESS_PROVIDER_NAMESPACE} key marking a message the loop or host synthesized rather than one a human sent. */
35
35
  export const SYNTHETIC_MESSAGE_KEY = "synthetic";
36
+ /**
37
+ * The {@link HARNESS_PROVIDER_NAMESPACE} key marking a synthetic message that is a RECORD OF FACT
38
+ * the reader is meant to see, as opposed to loop machinery they are not.
39
+ *
40
+ * Always accompanies {@link SYNTHETIC_MESSAGE_KEY} — a record is synthetic in the sense that matters
41
+ * to every turn-boundary reader, and must never open a turn. This key answers the one question
42
+ * `synthetic` alone cannot: whether the message should be RENDERED. The loop's truncation nudge must
43
+ * not be (it would show words nobody typed); an embedder's out-of-band record must be (it is the
44
+ * only trace in the conversation that the work happened at all).
45
+ */
46
+ export const SYNTHETIC_RECORD_KEY = "syntheticRecord";
36
47
  /** The {@link HARNESS_PROVIDER_NAMESPACE} key marking an assistant message whose production a client abort cut off. */
37
48
  export const INTERRUPTED_MESSAGE_KEY = "interrupted";
38
49
  /**
@@ -62,14 +73,18 @@ export function isInterruptedMessage(message) {
62
73
  return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[INTERRUPTED_MESSAGE_KEY] === true;
63
74
  }
64
75
  /**
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.
76
+ * Build a `user` message the LOOP synthesized rather than a human typing it — today, the nudge that
77
+ * continues a reply the model truncated at its output-token limit.
67
78
  *
68
79
  * It has to carry the `user` role because the wire format requires a user turn after a truncated
69
80
  * assistant message, but it is not user input, and the difference is load-bearing: a `user` message is
70
81
  * what OPENS a conversation turn, so an unmarked synthetic one reads as a spurious turn boundary —
71
82
  * splitting one turn into two for the token window and, worse, giving a tail-turn removal a cut point in
72
83
  * the middle of a turn. Marking it is what lets {@link isSyntheticUserMessage} keep those readers honest.
84
+ *
85
+ * This one is loop MACHINERY and is not rendered: showing it would put words on screen nobody typed.
86
+ * An embedder recording out-of-band work — an analysis run's outcome — wants
87
+ * {@link syntheticRecordMessage} instead, which is equally non-turn-opening but IS displayed.
73
88
  */
74
89
  export function syntheticUserMessage(text) {
75
90
  return {
@@ -78,10 +93,36 @@ export function syntheticUserMessage(text) {
78
93
  providerOptions: { [HARNESS_PROVIDER_NAMESPACE]: { [SYNTHETIC_MESSAGE_KEY]: true } },
79
94
  };
80
95
  }
81
- /** Whether the loop synthesized this message (see {@link syntheticUserMessage}) rather than a human sending it. */
96
+ /**
97
+ * Build a record of out-of-band work for an embedder to append between turns — an analysis run's
98
+ * outcome, which the model should read on its next turn but which nobody said.
99
+ *
100
+ * Synthetic in every sense that matters to a turn-boundary reader ({@link isSyntheticUserMessage} is
101
+ * true of it), so it cannot split a turn for the token window nor hand a tail retraction a mid-turn
102
+ * cut point. It differs from {@link syntheticUserMessage} in exactly one respect, and it is the
103
+ * decisive one: this is a fact the reader is entitled to see, so the display reconstruction emits it
104
+ * — as a `system` message, which is what it is — rather than dropping it as loop machinery.
105
+ *
106
+ * A host MUST build these here rather than assembling the `providerOptions` markers itself: the keys
107
+ * and namespace are shared with the turn-boundary and display predicates, and a hand-rolled copy
108
+ * would fork them silently — the message would store fine and then be read as a genuine turn start,
109
+ * or be dropped from the transcript with no indication why.
110
+ */
111
+ export function syntheticRecordMessage(text) {
112
+ return {
113
+ role: "user",
114
+ content: text,
115
+ providerOptions: { [HARNESS_PROVIDER_NAMESPACE]: { [SYNTHETIC_MESSAGE_KEY]: true, [SYNTHETIC_RECORD_KEY]: true } },
116
+ };
117
+ }
118
+ /** Whether the loop or the host synthesized this message (see {@link syntheticUserMessage}) rather than a human sending it. */
82
119
  export function isSyntheticUserMessage(message) {
83
120
  return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[SYNTHETIC_MESSAGE_KEY] === true;
84
121
  }
122
+ /** Whether this is a host-appended record of out-of-band work (see {@link syntheticRecordMessage}) — synthetic, but displayed. */
123
+ export function isSyntheticRecordMessage(message) {
124
+ return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[SYNTHETIC_RECORD_KEY] === true;
125
+ }
85
126
  function providerOptionsFromLegacy(block) {
86
127
  // A block may carry both a signed thinking signature and cache_control;
87
128
  // both are required for valid continuation, so both must survive.
@@ -20,7 +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
+ import { isInterruptedMessage, isSyntheticRecordMessage, isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
24
24
  function genericToolCall(block) {
25
25
  return {
26
26
  type: "tool-call",
@@ -84,7 +84,15 @@ export async function contentToCortexMessages(messages, resolveCard) {
84
84
  // interruption marker — that rides assistant rows — so this cannot interact
85
85
  // with the marker-fold logic below. The assistant rows it separated then
86
86
  // become adjacent and coalesce into one bubble, which is the shape we want.
87
- if (isSyntheticUserMessage(message.message))
87
+ //
88
+ // A host-appended RECORD is the exception, and the distinction is exactly why the
89
+ // two markers exist separately: it is equally synthetic for every turn-boundary
90
+ // reader, but it is a fact the reader is entitled to see — the only trace in the
91
+ // conversation that out-of-band work happened at all. Dropping it here would make
92
+ // an embedder's durable record write successfully and then be invisible, with
93
+ // nothing to indicate why.
94
+ const isRecord = isSyntheticRecordMessage(message.message);
95
+ if (isSyntheticUserMessage(message.message) && !isRecord)
88
96
  continue;
89
97
  const parts = await rowToParts(message.message, resolveCard);
90
98
  // The marker is read before the zero-parts drop so the flag can never be lost to it:
@@ -100,7 +108,15 @@ export async function contentToCortexMessages(messages, resolveCard) {
100
108
  }
101
109
  continue;
102
110
  }
103
- const role = message.message.role === "tool" ? "assistant" : message.message.role;
111
+ // A record carries the `user` role for the wire format and for the turn-boundary
112
+ // predicates, but nobody said it. Emitting it AS `user` would attribute
113
+ // system-authored text to the reader — a lie about who spoke, and one that would
114
+ // also mislead them about what they can retract. `system` is what it actually is.
115
+ const role = isRecord
116
+ ? "system"
117
+ : message.message.role === "tool"
118
+ ? "assistant"
119
+ : message.message.role;
104
120
  const prev = out[out.length - 1];
105
121
  if (prev && prev.role === role && role === "assistant") {
106
122
  prev.parts.push(...parts);
@@ -15,7 +15,7 @@ export async function backfillAiSdkMessageEnvelopes(client) {
15
15
  role: row.role,
16
16
  content: row.content_jsonb,
17
17
  });
18
- await client.query("UPDATE messages SET message_envelope = $1::jsonb WHERE thread_id = $2 AND seq = $3::bigint", [
18
+ await client.query("UPDATE messages SET message_envelope = $1::json WHERE thread_id = $2 AND seq = $3::bigint", [
19
19
  JSON.stringify(envelopeMessage(message)),
20
20
  row.thread_id,
21
21
  row.seq,
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { metrics } from "@opentelemetry/api";
24
24
  import { ResultAsync, ok, okAsync } from "neverthrow";
25
+ import { stripNulCharacters } from "../input-sanitization.js";
25
26
  import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
26
27
  import { countTokens } from "./count-tokens.js";
27
28
  import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, isSyntheticUserMessage, parseStoredMessageEnvelope, } from "./ai-sdk-message-storage.js";
@@ -36,11 +37,18 @@ function okVoid() {
36
37
  *
37
38
  * - a tool result, which in AI SDK terms is a `tool`-role message, so a mid-turn
38
39
  * 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.
40
+ * - a message the LOOP or the HOST synthesized (`syntheticUserMessage`) — the
41
+ * truncated-reply nudge the loop inserts mid-turn, and the record an embedder
42
+ * appends between turns for work that happened outside the conversation, such as
43
+ * an analysis run's outcome. Both carry the `user` role for the wire format and
44
+ * neither is user input. Reading one as a boundary would split one turn into
45
+ * two: the token window would evict half a turn, and `retractLastTurn` would cut
46
+ * its tail in the middle of a turn rather than at its head.
47
+ *
48
+ * A host-appended record therefore belongs to the turn preceding it, so a tail
49
+ * retraction that removes that turn removes the record with it. That is the
50
+ * accepted consequence of the exclusion, not a defect — the alternative, letting
51
+ * it open a turn, is the exact failure the marker exists to prevent.
44
52
  *
45
53
  * {@link GENUINE_USER_START_SQL} is the twin of this predicate over stored
46
54
  * envelopes; the two are built from the same constants so they cannot drift.
@@ -61,6 +69,22 @@ function isGenuineUserStart(message) {
61
69
  */
62
70
  const GENUINE_USER_START_SQL = `message_envelope->'message'->>'role' = 'user'
63
71
  AND message_envelope->'message'->'providerOptions'->'${HARNESS_PROVIDER_NAMESPACE}'->>'${SYNTHETIC_MESSAGE_KEY}' IS DISTINCT FROM 'true'`;
72
+ /**
73
+ * Serialize a message's storage envelope, dropping NUL from every string it
74
+ * carries ({@link stripNulCharacters}).
75
+ *
76
+ * A stored NUL does not break the insert on a `json` column, nor the whole-row
77
+ * reads — it breaks {@link GENUINE_USER_START_SQL}, whose JSON operators refuse
78
+ * to walk a document containing one, so a single poisoned row would make the
79
+ * thread's tail unretractable. The loop already strips NUL where it builds a
80
+ * tool result, so this scrubs nothing on the harness's own path; it holds the
81
+ * invariant for every other writer.
82
+ *
83
+ * The replacer sees values, not keys — the same accepted limit as the loop's.
84
+ */
85
+ function serializeEnvelope(message) {
86
+ return JSON.stringify(envelopeMessage(message), (_key, value) => (typeof value === "string" ? stripNulCharacters(value) : value));
87
+ }
64
88
  /**
65
89
  * Group rows (oldest-first) into turns at genuine-user-start boundaries.
66
90
  * Generic over the row shape so the token-windowed read (`loadRecent`) and
@@ -148,11 +172,13 @@ export function createThreadHistory(pool) {
148
172
  // prior so the first `err` short-circuits — and `withTransaction`
149
173
  // re-throws it to force ROLLBACK (a returned `err` that does not
150
174
  // reach `withTransaction` would COMMIT silently).
151
- messages.reduce((chain, message, i) => chain.andThen(() => tryMutation("thread-history.appendTurn.insert", () => client.query(`INSERT INTO messages (thread_id, seq, message_envelope, tokens)
152
- VALUES ($1, $2, $3::jsonb, $4)
175
+ messages.reduce((chain, message, i) => chain.andThen(() => tryMutation("thread-history.appendTurn.insert", () => client.query(
176
+ // `::json`, never `::jsonb` — see the column comment in the state-init DDL.
177
+ `INSERT INTO messages (thread_id, seq, message_envelope, tokens)
178
+ VALUES ($1, $2, $3::json, $4)
153
179
  ON CONFLICT (thread_id, seq) DO UPDATE
154
180
  SET message_envelope = EXCLUDED.message_envelope,
155
- tokens = EXCLUDED.tokens`, [threadId, startSeq + i, JSON.stringify(envelopeMessage(message)), countTokens(message.content)])).map(() => undefined)), okVoid()))
181
+ tokens = EXCLUDED.tokens`, [threadId, startSeq + i, serializeEnvelope(message), countTokens(message.content)])).map(() => undefined)), okVoid()))
156
182
  .andThen(() =>
157
183
  // Thread listings sort on `cortex_analysis_threads.updated_at`, and
158
184
  // the only other writer of it is the title update — so without this