@inflexa-ai/harness 0.12.0 → 0.14.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 (207) hide show
  1. package/dist/agents/conversation-agent.d.ts +9 -15
  2. package/dist/agents/conversation-agent.js +32 -31
  3. package/dist/agents/sandbox/cheminformatics-agent.js +1 -1
  4. package/dist/agents/sandbox/chromatin-agent.js +1 -1
  5. package/dist/agents/sandbox/dna-methylation-agent.js +1 -1
  6. package/dist/agents/sandbox/drug-repurposing-agent.js +5 -10
  7. package/dist/agents/sandbox/enrichment-agent.js +1 -1
  8. package/dist/agents/sandbox/genomic-variant-agent.js +1 -11
  9. package/dist/agents/sandbox/immune-profiling-agent.js +1 -1
  10. package/dist/agents/sandbox/index.js +0 -3
  11. package/dist/agents/sandbox/multi-omics-integration-agent.js +1 -1
  12. package/dist/agents/sandbox/network-agent.js +1 -1
  13. package/dist/agents/sandbox/pkpd-clinical-response-agent.js +3 -6
  14. package/dist/agents/sandbox/scientific-executor.js +1 -1
  15. package/dist/agents/sandbox/shared.d.ts +2 -2
  16. package/dist/agents/sandbox/shared.js +8 -14
  17. package/dist/agents/sandbox/translational-safety-agent.js +4 -7
  18. package/dist/agents/sandbox/types.d.ts +5 -5
  19. package/dist/agents/sandbox/types.js +4 -4
  20. package/dist/app/chat-turn.js +4 -1
  21. package/dist/app/message-assembly.d.ts +5 -2
  22. package/dist/app/message-assembly.js +7 -2
  23. package/dist/app/run-activity.d.ts +13 -0
  24. package/dist/app/run-activity.js +51 -0
  25. package/dist/config/resource-limits.d.ts +0 -7
  26. package/dist/config/resource-limits.js +0 -1
  27. package/dist/contracts/target-dossier.d.ts +10 -10
  28. package/dist/execution/dbos-run-launcher.js +1 -26
  29. package/dist/execution/report-runner.js +1 -1
  30. package/dist/execution/run-authorizer.d.ts +1 -1
  31. package/dist/execution/run-authorizer.js +1 -1
  32. package/dist/execution/run-event-parts.d.ts +44 -0
  33. package/dist/execution/run-event-parts.js +0 -0
  34. package/dist/execution/run-event-stream.d.ts +91 -0
  35. package/dist/execution/run-event-stream.js +212 -0
  36. package/dist/execution/run-launcher.d.ts +2 -18
  37. package/dist/execution/run-launcher.js +2 -4
  38. package/dist/execution/run-synthesis.js +1 -1
  39. package/dist/index.d.ts +7 -4
  40. package/dist/index.js +23 -6
  41. package/dist/input-sanitization.d.ts +14 -4
  42. package/dist/input-sanitization.js +27 -4
  43. package/dist/lib/storage.js +4 -1
  44. package/dist/loop/budget-exceeded.d.ts +18 -2
  45. package/dist/loop/budget-exceeded.js +49 -2
  46. package/dist/loop/run-agent.d.ts +22 -1
  47. package/dist/loop/run-agent.js +51 -5
  48. package/dist/loop/run-to-terminal.d.ts +0 -2
  49. package/dist/loop/run-to-terminal.js +8 -1
  50. package/dist/memory/ai-sdk-message-storage.d.ts +37 -4
  51. package/dist/memory/ai-sdk-message-storage.js +45 -4
  52. package/dist/memory/content-to-cortex.js +19 -3
  53. package/dist/memory/message-backfill.js +1 -1
  54. package/dist/memory/reconstruct-cards.d.ts +1 -1
  55. package/dist/memory/reconstruct-cards.js +7 -5
  56. package/dist/memory/thread-history.js +72 -8
  57. package/dist/prompts/conversation.d.ts +1 -1
  58. package/dist/prompts/conversation.js +41 -15
  59. package/dist/prompts/literature-reviewer.d.ts +1 -1
  60. package/dist/prompts/literature-reviewer.js +16 -14
  61. package/dist/prompts/planner.d.ts +3 -0
  62. package/dist/prompts/planner.js +16 -21
  63. package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
  64. package/dist/prompts/sandbox/drug-repurposing-agent.js +31 -22
  65. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  66. package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -10
  67. package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
  68. package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +8 -7
  69. package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
  70. package/dist/prompts/sandbox/translational-safety-agent.js +20 -14
  71. package/dist/prompts/sandbox-standards.d.ts +4 -4
  72. package/dist/prompts/sandbox-standards.js +5 -5
  73. package/dist/prompts/synthesis-agent.d.ts +1 -1
  74. package/dist/prompts/synthesis-agent.js +7 -7
  75. package/dist/providers/errors.d.ts +7 -0
  76. package/dist/providers/errors.js +88 -3
  77. package/dist/runtime/assemble.d.ts +5 -10
  78. package/dist/runtime/assemble.js +0 -4
  79. package/dist/runtime/boot.d.ts +2 -2
  80. package/dist/runtime/boot.js +1 -1
  81. package/dist/runtime/dbos.d.ts +4 -5
  82. package/dist/runtime/dbos.js +6 -7
  83. package/dist/runtime/pools.d.ts +15 -1
  84. package/dist/runtime/pools.js +15 -1
  85. package/dist/sandbox/mount-plan.d.ts +1 -1
  86. package/dist/sandbox/sandbox-error.d.ts +1 -1
  87. package/dist/sandbox/sandbox-error.js +1 -1
  88. package/dist/sandbox/sandbox-step-translate.d.ts +15 -2
  89. package/dist/sandbox/sandbox-step-translate.js +17 -2
  90. package/dist/sandbox/types.d.ts +1 -1
  91. package/dist/schemas/validate-plan.d.ts +1 -1
  92. package/dist/schemas/workflow-state.d.ts +2 -2
  93. package/dist/schemas/workflow-state.js +2 -2
  94. package/dist/state/data-profile.d.ts +53 -1
  95. package/dist/state/data-profile.js +66 -6
  96. package/dist/state/index.d.ts +6 -6
  97. package/dist/state/index.js +4 -4
  98. package/dist/state/init.js +37 -2
  99. package/dist/state/plans.js +1 -1
  100. package/dist/state/runs.d.ts +49 -0
  101. package/dist/state/runs.js +150 -0
  102. package/dist/tasks/data-profile-activity.d.ts +87 -0
  103. package/dist/tasks/data-profile-activity.js +56 -0
  104. package/dist/tasks/data-profile.js +66 -4
  105. package/dist/tools/__fixtures__/tool-context.js +1 -0
  106. package/dist/tools/ad-hoc-router.d.ts +31 -0
  107. package/dist/tools/ad-hoc-router.js +132 -0
  108. package/dist/tools/analysis-invocation.d.ts +4 -0
  109. package/dist/tools/analysis-invocation.js +17 -0
  110. package/dist/tools/bio/chembl.d.ts +1 -1
  111. package/dist/tools/bio/chembl.js +51 -54
  112. package/dist/tools/bio/comptox.js +35 -33
  113. package/dist/tools/bio/drug-gene-interactions.d.ts +76 -0
  114. package/dist/tools/bio/drug-gene-interactions.js +175 -0
  115. package/dist/tools/bio/gene-disease-evidence.d.ts +61 -0
  116. package/dist/tools/bio/gene-disease-evidence.js +187 -0
  117. package/dist/tools/bio/gene-preclinical-profile.d.ts +58 -0
  118. package/dist/tools/bio/gene-preclinical-profile.js +125 -0
  119. package/dist/tools/bio/index.d.ts +23 -16
  120. package/dist/tools/bio/index.js +29 -22
  121. package/dist/tools/bio/keys.d.ts +16 -9
  122. package/dist/tools/bio/keys.js +22 -13
  123. package/dist/tools/bio/lookup-annotation.d.ts +48 -0
  124. package/dist/tools/bio/lookup-annotation.js +173 -0
  125. package/dist/tools/bio/opentargets.d.ts +7 -11
  126. package/dist/tools/bio/opentargets.js +24 -35
  127. package/dist/tools/bio/pubchem.js +6 -4
  128. package/dist/tools/bio/pubmed.d.ts +15 -7
  129. package/dist/tools/bio/pubmed.js +79 -20
  130. package/dist/tools/bio/search-clinical-trials.d.ts +20 -1
  131. package/dist/tools/bio/search-clinical-trials.js +26 -5
  132. package/dist/tools/bio/search-faers.js +9 -3
  133. package/dist/tools/bio/search-gene.d.ts +0 -1
  134. package/dist/tools/bio/search-gene.js +4 -5
  135. package/dist/tools/bio/search-geo-datasets.js +10 -4
  136. package/dist/tools/bio/search-interactions.d.ts +8 -4
  137. package/dist/tools/bio/search-interactions.js +28 -12
  138. package/dist/tools/bio/target-safety.d.ts +59 -0
  139. package/dist/tools/bio/target-safety.js +191 -0
  140. package/dist/tools/define-tool.d.ts +6 -2
  141. package/dist/tools/define-tool.js +1 -1
  142. package/dist/tools/execute-analysis.d.ts +44 -0
  143. package/dist/tools/execute-analysis.js +252 -0
  144. package/dist/tools/iterate-report.js +7 -6
  145. package/dist/tools/lib/dgidb-client.d.ts +49 -0
  146. package/dist/tools/lib/dgidb-client.js +168 -0
  147. package/dist/tools/lib/disgenet-client.d.ts +56 -0
  148. package/dist/tools/lib/disgenet-client.js +59 -0
  149. package/dist/tools/lib/drugbank-client.d.ts +83 -0
  150. package/dist/tools/lib/drugbank-client.js +95 -0
  151. package/dist/tools/lib/ensembl-client.d.ts +4 -1
  152. package/dist/tools/lib/ensembl-client.js +5 -3
  153. package/dist/tools/lib/gwas-catalog-client.d.ts +60 -0
  154. package/dist/tools/lib/gwas-catalog-client.js +136 -0
  155. package/dist/tools/lib/pharmgkb-client.d.ts +22 -0
  156. package/dist/tools/lib/pharmgkb-client.js +32 -0
  157. package/dist/tools/lib/pubmed-client.d.ts +57 -4
  158. package/dist/tools/lib/pubmed-client.js +57 -3
  159. package/dist/tools/lib/string-client.d.ts +25 -4
  160. package/dist/tools/lib/string-client.js +22 -5
  161. package/dist/tools/research/generate-plan.d.ts +16 -9
  162. package/dist/tools/research/generate-plan.js +116 -70
  163. package/dist/tools/research/inspect-data-profile.d.ts +31 -6
  164. package/dist/tools/research/inspect-data-profile.js +23 -7
  165. package/dist/tools/research/inspect-run.d.ts +33 -15
  166. package/dist/tools/research/inspect-run.js +155 -66
  167. package/dist/tools/research/literature-reviewer.js +7 -11
  168. package/dist/workflows/execute-analysis.d.ts +68 -5
  169. package/dist/workflows/execute-analysis.js +76 -7
  170. package/dist/workflows/sandbox-step.js +1 -11
  171. package/dist/workflows/target-assessment/decisions/index.d.ts +1 -1
  172. package/dist/workflows/target-assessment/phase4-assemble.d.ts +1 -1
  173. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +2 -2
  174. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -1
  175. package/package.json +1 -1
  176. package/dist/agents/sandbox/ephemeral-executor.d.ts +0 -9
  177. package/dist/agents/sandbox/ephemeral-executor.js +0 -20
  178. package/dist/execution/ephemeral-runner.d.ts +0 -98
  179. package/dist/execution/ephemeral-runner.js +0 -189
  180. package/dist/prompts/sandbox/ephemeral-executor.d.ts +0 -1
  181. package/dist/prompts/sandbox/ephemeral-executor.js +0 -66
  182. package/dist/tools/bio/check-safety-panel.d.ts +0 -35
  183. package/dist/tools/bio/check-safety-panel.js +0 -76
  184. package/dist/tools/bio/get-impc-ko-profile.d.ts +0 -9
  185. package/dist/tools/bio/get-impc-ko-profile.js +0 -23
  186. package/dist/tools/bio/lookup-go-term.d.ts +0 -31
  187. package/dist/tools/bio/lookup-go-term.js +0 -116
  188. package/dist/tools/bio/search-bgee-expression.d.ts +0 -10
  189. package/dist/tools/bio/search-bgee-expression.js +0 -30
  190. package/dist/tools/bio/search-clinvar.d.ts +0 -13
  191. package/dist/tools/bio/search-clinvar.js +0 -28
  192. package/dist/tools/bio/search-dgidb.d.ts +0 -53
  193. package/dist/tools/bio/search-dgidb.js +0 -213
  194. package/dist/tools/bio/search-disgenet.d.ts +0 -30
  195. package/dist/tools/bio/search-disgenet.js +0 -84
  196. package/dist/tools/bio/search-drugbank.d.ts +0 -38
  197. package/dist/tools/bio/search-drugbank.js +0 -109
  198. package/dist/tools/bio/search-gwas-catalog.d.ts +0 -33
  199. package/dist/tools/bio/search-gwas-catalog.js +0 -172
  200. package/dist/tools/bio/search-pathway.d.ts +0 -12
  201. package/dist/tools/bio/search-pathway.js +0 -32
  202. package/dist/tools/bio/search-pharmgkb.d.ts +0 -17
  203. package/dist/tools/bio/search-pharmgkb.js +0 -52
  204. package/dist/tools/execute-plan.d.ts +0 -64
  205. package/dist/tools/execute-plan.js +0 -211
  206. package/dist/tools/run-ephemeral.d.ts +0 -33
  207. package/dist/tools/run-ephemeral.js +0 -65
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export type { ProvenanceCollector } from "./provenance/collector.js";
23
23
  export { UnavailablePreviewPublisher } from "./tools/report/preview-publisher.js";
24
24
  export type { PreviewPublisher, PreviewMintResult } from "./tools/report/preview-publisher.js";
25
25
  export { createDbosRunLauncher } from "./execution/dbos-run-launcher.js";
26
- export type { RunLauncher, LaunchOptions, LaunchRunOptions, LaunchOutcome } from "./execution/run-launcher.js";
26
+ export type { RunLauncher, LaunchOptions } from "./execution/run-launcher.js";
27
27
  export { defineTool, isToolError } from "./tools/define-tool.js";
28
28
  export type { Tool, ToolDefinition, ToolContext, ToolError } from "./tools/define-tool.js";
29
29
  export { createAskGateway } from "./tools/approval/gateway.js";
@@ -54,6 +54,7 @@ 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
56
  export type { ThreadHistory, StoredMessage, MessagePage, RetractOutcome } from "./memory/thread-history.js";
57
+ export { syntheticUserMessage, syntheticRecordMessage, isSyntheticUserMessage, isSyntheticRecordMessage } from "./memory/ai-sdk-message-storage.js";
57
58
  export { contentToCortexMessages } from "./memory/content-to-cortex.js";
58
59
  export { createCardResolver } from "./memory/reconstruct-cards.js";
59
60
  export type { ToolCardResolver, StoredToolCallForCard } from "./memory/reconstruct-cards.js";
@@ -88,9 +89,11 @@ export { deliverExecEvent, execEventTopic } from "./sandbox/deliver-exec-event.j
88
89
  export { ExecEventMessageSchema } from "./sandbox/types.js";
89
90
  export type { ExecEventMessage, DoneMarker } from "./sandbox/types.js";
90
91
  export { registerExecuteAnalysis } from "./workflows/execute-analysis.js";
91
- export type { ExecuteAnalysisDeps, ExecuteAnalysisInput, ExecuteAnalysisResult, RunProvenanceEvent } from "./workflows/execute-analysis.js";
92
+ export type { ExecuteAnalysisDeps, ExecuteAnalysisInput, ExecuteAnalysisResult, RunObservation, RunProvenanceEvent, RunStepObservation, StepRuntimeStatus, } from "./workflows/execute-analysis.js";
92
93
  export { registerSandboxStep } from "./workflows/sandbox-step.js";
93
94
  export type { SandboxStepDeps, SandboxStepInput, SandboxStepResult, SandboxAgentBuildContext } from "./workflows/sandbox-step.js";
95
+ export { createRunEventStream } from "./execution/run-event-stream.js";
96
+ export type { RunEventStream, RunEventStreamDeps, RunEventSubscribeOptions, RunEventPartHandler } from "./execution/run-event-stream.js";
94
97
  export { createSandboxAgents, SANDBOX_AGENT_META } from "./agents/sandbox/index.js";
95
98
  export type { SandboxAgentDeps, SandboxStepCoords, AgentMeta } from "./agents/sandbox/index.js";
96
99
  export { AnalysisPlanSchema } from "./schemas/workflow-state.js";
@@ -101,8 +104,8 @@ export type { ValidatePlanOptions } from "./schemas/validate-plan.js";
101
104
  export type { ValidationResult } from "./schemas/validate-plan.js";
102
105
  export { upsertPlan, loadPlan } from "./state/plans.js";
103
106
  export type { UpsertPlanInput } from "./state/plans.js";
104
- export { insertRun, queryActiveRun, updateRunStatus, queryRun, queryRunsByAnalysis, RunDedupCollisionError } from "./state/runs.js";
105
- export type { InsertRunInput } from "./state/runs.js";
107
+ export { insertRun, reserveRunById, queryActiveRun, queryActiveRunsByAnalysis, updateRunStatus, queryRun, queryRunsByAnalysis, RunDedupCollisionError, RunIdentityCollisionError, } from "./state/runs.js";
108
+ export type { InsertRunInput, RunReservation } from "./state/runs.js";
106
109
  export { queryStepsByRun } from "./state/step-executions.js";
107
110
  export type { CortexRunRow, StepExecutionRow, RunStatus } from "./state/schema.js";
108
111
  export type { DbError } from "./lib/db-result.js";
package/dist/index.js CHANGED
@@ -81,6 +81,16 @@ export { createStreamingChat } from "./providers/streaming-chat.js";
81
81
  export { prepareChatTurn } from "./app/chat-turn.js";
82
82
  export { createThreadStore } from "./memory/thread-store.js";
83
83
  export { createThreadHistory } from "./memory/thread-history.js";
84
+ // The synthetic-message primitives. A host appends a record of out-of-band work (an analysis run's
85
+ // outcome) into a thread through `appendTurn`, and needs a message the model reads but that is not
86
+ // user input — which is exactly what the marker denotes. The constructor is exported rather than
87
+ // the marker key so a host can never hand-assemble a message that the turn-boundary predicates
88
+ // (`isGenuineUserStart` and its SQL twin) would then fail to recognise; `isSyntheticUserMessage`
89
+ // lets a host renderer tell such a message apart from a genuine user turn on the way back out.
90
+ // `syntheticRecordMessage` is the one a HOST reaches for: equally non-turn-opening, but rendered —
91
+ // the display reconstruction emits it as a `system` message rather than dropping it as loop
92
+ // machinery, so an appended run outcome is visible in the transcript instead of silently absent.
93
+ export { syntheticUserMessage, syntheticRecordMessage, isSyntheticUserMessage, isSyntheticRecordMessage } from "./memory/ai-sdk-message-storage.js";
84
94
  export { contentToCortexMessages } from "./memory/content-to-cortex.js";
85
95
  export { createCardResolver } from "./memory/reconstruct-cards.js";
86
96
  export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./contracts/part-registry.js";
@@ -89,11 +99,9 @@ export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./cont
89
99
  // the staged-input manifest contract, the sandbox/workspace factories over its
90
100
  // workspace tree, and the exec-callback envelope pieces (the HTTP→DBOS-topic
91
101
  // ingress is the embedder's to host; see the sandbox-server callback protocol).
92
- // DBOS lifecycle. `sweepEphemeralWorkflows` is a pre-launch boot duty: once the
93
- // `ephemeral` workflow is registered, a host crash mid-run leaves a PENDING
94
- // `ephemeral:*` row that the next launch's recovery would re-dispatch (a sandbox
95
- // for a chat turn that no longer exists). The embedder sweeps it with a direct
96
- // system-DB UPDATE between state-init and `launchDbos` — the only race-free point.
102
+ // DBOS lifecycle. `sweepEphemeralWorkflows` is a pre-launch upgrade migration:
103
+ // older releases could leave PENDING `ephemeral:*` rows that recovery would
104
+ // otherwise re-dispatch. No current workflow creates that prefix.
97
105
  export { launchDbos, shutdownDbos, sweepEphemeralWorkflows } from "./runtime/dbos.js";
98
106
  // DBOS workflow-status vocabulary. An embedder that reads its own
99
107
  // `dbos.workflow_status` rows must classify them against the SDK's status set,
@@ -140,6 +148,15 @@ export { ExecEventMessageSchema } from "./sandbox/types.js";
140
148
  // these, but an embedder running only the run engine registers them directly.
141
149
  export { registerExecuteAnalysis } from "./workflows/execute-analysis.js";
142
150
  export { registerSandboxStep } from "./workflows/sandbox-step.js";
151
+ // Run-event read seam — the fine-grained durable channel beside `observeRun`.
152
+ // The two are a deliberate pair, not a duplication: `observeRun` is the coarse,
153
+ // in-process run/step snapshot a workflow hands its host synchronously, and it
154
+ // defers sub-step detail to the run's durable event stream. This is that stream,
155
+ // read back — the per-tool-call activity, file trees, and step summaries a run
156
+ // writes, fanned in from the parent workflow and every sandbox-step child.
157
+ // Nothing in the signature belongs to the durability engine, so an embedder
158
+ // consumes run events without depending on it.
159
+ export { createRunEventStream } from "./execution/run-event-stream.js";
143
160
  // Sandbox-agent catalog. `buildAgent` maps a `SandboxAgentBuildContext` onto
144
161
  // `SandboxAgentDeps`, then selects the per-step agent by id from this record;
145
162
  // `SANDBOX_AGENT_META` is the planner-facing meta the same ids key.
@@ -157,7 +174,7 @@ export { validatePlan } from "./schemas/validate-plan.js";
157
174
  // caller-derived id and inserts-if-absent (the deterministic-id intake path);
158
175
  // the run-state calls mirror the chat trigger's reserve → dedup → status flow.
159
176
  export { upsertPlan, loadPlan } from "./state/plans.js";
160
- export { insertRun, queryActiveRun, updateRunStatus, queryRun, queryRunsByAnalysis, RunDedupCollisionError } from "./state/runs.js";
177
+ export { insertRun, reserveRunById, queryActiveRun, queryActiveRunsByAnalysis, updateRunStatus, queryRun, queryRunsByAnalysis, RunDedupCollisionError, RunIdentityCollisionError, } from "./state/runs.js";
161
178
  export { queryStepsByRun } from "./state/step-executions.js";
162
179
  // Host-neutral reference-data catalog and shared installation receipts. Each
163
180
  // artifact names the third-party upstream it is fetched from; embedders own all
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Input sanitization — two pure, always-on functions applied once to the
3
- * incoming user message by the chat route (change 6). Never applied to
4
- * assistant messages or tool results.
2
+ * Input sanitization — pure, always-on text functions, not a plug-in pipeline.
3
+ * What runs is exactly what is declared here.
5
4
  *
6
- * Two pure functions, not a plug-in pipeline. What runs is exactly these two.
5
+ * `normalizeUnicode` and `redactSecrets` are applied once to the incoming user
6
+ * message by the chat route (change 6), and never to assistant messages or tool
7
+ * results. `stripNulCharacters` is the exception — it applies to tool results.
7
8
  */
8
9
  /**
9
10
  * NFC-normalize and strip control characters.
@@ -13,6 +14,15 @@
13
14
  * that could smuggle formatting or terminal escapes.
14
15
  */
15
16
  export declare function normalizeUnicode(text: string): string;
17
+ /**
18
+ * Strip every NUL from `text`. Applied where a tool result is built — so the
19
+ * loop's in-memory message and the stored row agree byte-for-byte and the
20
+ * prompt-cache prefix survives the round trip — and again where the turn is
21
+ * written, which holds the invariant for writers that are not the loop. User
22
+ * input needs no separate treatment: `normalizeUnicode` already strips NUL along
23
+ * with the rest of the C0 range.
24
+ */
25
+ export declare function stripNulCharacters(text: string): string;
16
26
  /**
17
27
  * Replace structured secret formats with `[REDACTED: <name>]`.
18
28
  *
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Input sanitization — two pure, always-on functions applied once to the
3
- * incoming user message by the chat route (change 6). Never applied to
4
- * assistant messages or tool results.
2
+ * Input sanitization — pure, always-on text functions, not a plug-in pipeline.
3
+ * What runs is exactly what is declared here.
5
4
  *
6
- * Two pure functions, not a plug-in pipeline. What runs is exactly these two.
5
+ * `normalizeUnicode` and `redactSecrets` are applied once to the incoming user
6
+ * message by the chat route (change 6), and never to assistant messages or tool
7
+ * results. `stripNulCharacters` is the exception — it applies to tool results.
7
8
  */
8
9
  /**
9
10
  * C0/C1 control characters and DEL, excluding `\t` (U+0009) and `\n`
@@ -21,6 +22,28 @@ const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
21
22
  export function normalizeUnicode(text) {
22
23
  return text.normalize("NFC").replace(CONTROL_CHARS, "");
23
24
  }
25
+ /**
26
+ * NUL (U+0000) — the one character a stored message cannot carry. Postgres
27
+ * refuses it inside JSON: a `jsonb` column rejects the insert outright, and a
28
+ * `json` column accepts it but errors the moment a JSON operator reads that row
29
+ * ("U+0000 cannot be converted to text"), which is what `retractLastTurn`'s
30
+ * boundary predicate does. Either way one NUL byte poisons a thread, and it
31
+ * arrives through no fault of the model: tool results carry raw command output
32
+ * and file bytes, which is why they are otherwise left unsanitized.
33
+ */
34
+ // eslint-disable-next-line no-control-regex -- matching NUL is the intent; see above.
35
+ const NUL = /\u0000/g;
36
+ /**
37
+ * Strip every NUL from `text`. Applied where a tool result is built — so the
38
+ * loop's in-memory message and the stored row agree byte-for-byte and the
39
+ * prompt-cache prefix survives the round trip — and again where the turn is
40
+ * written, which holds the invariant for writers that are not the loop. User
41
+ * input needs no separate treatment: `normalizeUnicode` already strips NUL along
42
+ * with the rest of the C0 range.
43
+ */
44
+ export function stripNulCharacters(text) {
45
+ return text.replace(NUL, "");
46
+ }
24
47
  /**
25
48
  * Each entry redacts one structured, prefixed secret format. Every pattern
26
49
  * has a prefix (`AKIA`, `sk-`, `gh*_`, `eyJ`, `Bearer`, a DB scheme) that
@@ -7,7 +7,7 @@
7
7
  * the composition root and threaded as a constructor dep (see the harness-durable-runtime spec).
8
8
  */
9
9
  import pg from "pg";
10
- import { resolveAppPoolSize } from "../runtime/pools.js";
10
+ import { APP_POOL_ACQUIRE_TIMEOUT_MS, resolveAppPoolSize } from "../runtime/pools.js";
11
11
  function poolOptions(config) {
12
12
  return {
13
13
  host: config.host,
@@ -18,6 +18,9 @@ function poolOptions(config) {
18
18
  // Per-pod app pool; DBOS owns its system-DB connections separately
19
19
  // (see the postgres-storage-backend spec). The boot guard checks this fits `max_connections`.
20
20
  max: resolveAppPoolSize(config.poolMax),
21
+ // Acquisition is bounded so a saturated pool fails a caller instead of
22
+ // hanging it — see `APP_POOL_ACQUIRE_TIMEOUT_MS` for the invariant.
23
+ connectionTimeoutMillis: APP_POOL_ACQUIRE_TIMEOUT_MS,
21
24
  // `pg` accepts `ssl: false` to disable entirely, or an object with
22
25
  // `rejectUnauthorized` to tune TLS. We map sslmode to those forms.
23
26
  ssl: config.sslMode === "disable"
@@ -6,15 +6,31 @@
6
6
  * is wasteful because every subsequent call charged to the same budget will
7
7
  * also fail until the user tops up.
8
8
  *
9
- * Classification order (first hit wins):
9
+ * Classification order (first hit wins), and the order is load-bearing:
10
10
  * 1. Structured `statusCode === 402` (or `status === 402`) on any
11
11
  * link of the cause chain. The gateway emits 402 exclusively for
12
12
  * `budget_exceeded`, so a structured 402 is unambiguous.
13
- * 2. Pattern fallback: `/budget.?exceeded/i` on the stringified
13
+ * 2. A `ProviderError` on the value or its cause chain that was decided on
14
+ * a status: its `type` answers, and the text patterns are skipped.
15
+ * 3. Pattern fallback: `/budget.?exceeded/i` on the stringified
14
16
  * top-level error message. Covers stringified throws and paths
15
17
  * where no `statusCode` is attached (ops scripts, sandbox-server
16
18
  * surfacing upstream billing errors as text).
17
19
  *
20
+ * Step 2 exists because the text heuristic must never outrank a real
21
+ * classification. A `ProviderError` message carries an excerpt of the provider's
22
+ * response body, so a non-retryable 400 whose body merely mentions a budget would
23
+ * otherwise match step 3 — and the callers of this predicate act on it fatally
24
+ * (`sandbox-step` self-cancels the workflow, `execute-analysis` re-raises it as a
25
+ * budget throw). A phrase inside a quoted body is not evidence against the status
26
+ * the failure actually carried.
27
+ *
28
+ * "Decided on a status" is the load-bearing qualifier. `toProviderError` wraps
29
+ * every throwable, including ones `classifyProviderError` had no status for — it
30
+ * returns `provider` as a fall-through default there, which is an absence of
31
+ * classification, not a determination. Treating that as authoritative would
32
+ * silence step 3 in precisely the case it was written for.
33
+ *
18
34
  * Returns `false` for all other errors. Safe to call with any value;
19
35
  * non-Error inputs hit the stringified-pattern branch and return `false`
20
36
  * unless the string happens to match.
@@ -6,19 +6,36 @@
6
6
  * is wasteful because every subsequent call charged to the same budget will
7
7
  * also fail until the user tops up.
8
8
  *
9
- * Classification order (first hit wins):
9
+ * Classification order (first hit wins), and the order is load-bearing:
10
10
  * 1. Structured `statusCode === 402` (or `status === 402`) on any
11
11
  * link of the cause chain. The gateway emits 402 exclusively for
12
12
  * `budget_exceeded`, so a structured 402 is unambiguous.
13
- * 2. Pattern fallback: `/budget.?exceeded/i` on the stringified
13
+ * 2. A `ProviderError` on the value or its cause chain that was decided on
14
+ * a status: its `type` answers, and the text patterns are skipped.
15
+ * 3. Pattern fallback: `/budget.?exceeded/i` on the stringified
14
16
  * top-level error message. Covers stringified throws and paths
15
17
  * where no `statusCode` is attached (ops scripts, sandbox-server
16
18
  * surfacing upstream billing errors as text).
17
19
  *
20
+ * Step 2 exists because the text heuristic must never outrank a real
21
+ * classification. A `ProviderError` message carries an excerpt of the provider's
22
+ * response body, so a non-retryable 400 whose body merely mentions a budget would
23
+ * otherwise match step 3 — and the callers of this predicate act on it fatally
24
+ * (`sandbox-step` self-cancels the workflow, `execute-analysis` re-raises it as a
25
+ * budget throw). A phrase inside a quoted body is not evidence against the status
26
+ * the failure actually carried.
27
+ *
28
+ * "Decided on a status" is the load-bearing qualifier. `toProviderError` wraps
29
+ * every throwable, including ones `classifyProviderError` had no status for — it
30
+ * returns `provider` as a fall-through default there, which is an absence of
31
+ * classification, not a determination. Treating that as authoritative would
32
+ * silence step 3 in precisely the case it was written for.
33
+ *
18
34
  * Returns `false` for all other errors. Safe to call with any value;
19
35
  * non-Error inputs hit the stringified-pattern branch and return `false`
20
36
  * unless the string happens to match.
21
37
  */
38
+ import { extractStatus, isProviderError } from "../providers/errors.js";
22
39
  const BUDGET_EXCEEDED_PATTERNS = [/budget.?exceeded/i];
23
40
  /** Max depth walked on the cause chain looking for a structured statusCode. */
24
41
  const MAX_CAUSE_HOPS = 5;
@@ -33,9 +50,39 @@ function hasStatus402(err) {
33
50
  }
34
51
  return false;
35
52
  }
53
+ /**
54
+ * Find a `ProviderError` on the value or its cause chain. A `ResultError`
55
+ * thrown at a step boundary carries the structured value on `.cause`, which is
56
+ * the shape the callers of this predicate actually catch.
57
+ */
58
+ function findProviderError(err) {
59
+ let cursor = err;
60
+ for (let i = 0; i < MAX_CAUSE_HOPS && cursor; i++) {
61
+ if (isProviderError(cursor))
62
+ return cursor;
63
+ cursor = cursor.cause;
64
+ }
65
+ return undefined;
66
+ }
36
67
  export function isBudgetExceeded(err) {
37
68
  if (hasStatus402(err))
38
69
  return true;
70
+ const classified = findProviderError(err);
71
+ if (classified) {
72
+ if (classified.type === "budget")
73
+ return true;
74
+ // `auth` and `tenant-blocked` exist only because a status produced them, and
75
+ // a `provider` arm that carried a status was decided on it. None can be
76
+ // overturned by a phrase inside a response body the message merely quotes.
77
+ //
78
+ // A `provider` arm with NO status is the one exception: there
79
+ // `classifyProviderError` had nothing to key on and applied a fall-through
80
+ // default, so it made no determination to respect. That is exactly the shape
81
+ // a gateway reporting `budget_exceeded` as plain text arrives in, and the
82
+ // patterns below are the reason it is still caught.
83
+ if (classified.type !== "provider" || extractStatus(classified) !== undefined)
84
+ return false;
85
+ }
39
86
  const msg = err instanceof Error ? err.message : String(err ?? "");
40
87
  return BUDGET_EXCEEDED_PATTERNS.some((p) => p.test(msg));
41
88
  }
@@ -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,
@@ -40,6 +51,7 @@ export async function runAgent(agent, initial, session, opts) {
40
51
  const unavailableAsk = new UnavailableAsk();
41
52
  const ask = opts.ask ?? ((request) => unavailableAsk.ask(request));
42
53
  const toolCtx = (tu) => ({
54
+ invocationId: tu.toolCallId,
43
55
  session,
44
56
  signal,
45
57
  emit,
@@ -53,6 +65,12 @@ export async function runAgent(agent, initial, session, opts) {
53
65
  // byte-identical to be read back.
54
66
  const providerOptions = promptCacheProviderOptions(opts.promptCache ?? DEFAULT_PROMPT_CACHE);
55
67
  const usage = {};
68
+ // Exactly one record per completed run — never one per iteration. That bound is
69
+ // what keeps the default level affordable for an agent that runs long; the
70
+ // per-iteration detail lives at `debug`, where paying per iteration is the point.
71
+ const logFinish = (level, reason, cappedOut) => {
72
+ log[level]("run finished", { iterations, reason, cappedOut, truncationRecoveries, usage });
73
+ };
56
74
  // The user said no. A subsequent model call would only let the agent argue
57
75
  // with the decision, or spend a call acknowledging it; the denial tool result
58
76
  // is itself what the surface renders, so the turn ends the moment a denial
@@ -61,14 +79,21 @@ export async function runAgent(agent, initial, session, opts) {
61
79
  const stopOnDenial = async (i) => {
62
80
  await emit({ type: "iteration", source, index: i, final: true });
63
81
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
82
+ logFinish("warn", "denied", false);
64
83
  return { messages, finish: { reason: "denied", cappedOut: false, truncationRecoveries } };
65
84
  };
85
+ const stopOnResolved = async (i) => {
86
+ await emit({ type: "iteration", source, index: i, final: true });
87
+ recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
88
+ return { messages, finish: { reason: "stop", cappedOut: false, truncationRecoveries } };
89
+ };
66
90
  for (let i = 0; i < agent.maxIterations; i++) {
67
91
  iterations = i + 1;
68
92
  const request = {
69
93
  system: agent.systemPrompt,
70
94
  messages,
71
95
  tools: toolDefs,
96
+ ...(opts.toolChoice !== undefined ? { toolChoice: opts.toolChoice } : {}),
72
97
  providerOptions,
73
98
  };
74
99
  const reply = await resultStep(runStep)(formatStepName.llm(i), () => provider.chat(request, session, signal));
@@ -92,6 +117,9 @@ export async function runAgent(agent, initial, session, opts) {
92
117
  if (reply.finishReason === "length") {
93
118
  truncationRecoveries++;
94
119
  await emit({ type: "iteration", source, index: i, final: false });
120
+ // `tools` is what the model asked for; the trailing call was cut off at the
121
+ // output limit and is never dispatched, which the distinct message records.
122
+ log.debug("iteration truncated at output limit", { iteration: i, tools: toolCalls.map((t) => t.toolName), truncationRecoveries });
95
123
  if (toolCalls.length === 0) {
96
124
  // Stamped synthetic, not left as a bare `user` message: the wire format needs a user turn
97
125
  // after a truncated assistant message, but this one is the loop's own nudge, and thread
@@ -120,14 +148,18 @@ export async function runAgent(agent, initial, session, opts) {
120
148
  messages.push({ role: "tool", content: results });
121
149
  if (hasDenial(results))
122
150
  return stopOnDenial(i);
151
+ if (opts.resolved?.())
152
+ return stopOnResolved(i);
123
153
  continue;
124
154
  }
125
155
  if (reply.finishReason !== "tool-calls") {
126
156
  await emit({ type: "iteration", source, index: i, final: true });
127
157
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
158
+ logFinish("info", reply.finishReason, false);
128
159
  return { messages, finish: { reason: reply.finishReason, cappedOut: false, truncationRecoveries } };
129
160
  }
130
161
  await emit({ type: "iteration", source, index: i, final: false });
162
+ log.debug("iteration", { iteration: i, tools: toolCalls.map((t) => t.toolName) });
131
163
  for (const tu of toolCalls) {
132
164
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input });
133
165
  }
@@ -145,6 +177,8 @@ export async function runAgent(agent, initial, session, opts) {
145
177
  messages.push({ role: "tool", content: results });
146
178
  if (hasDenial(results))
147
179
  return stopOnDenial(i);
180
+ if (opts.resolved?.())
181
+ return stopOnResolved(i);
148
182
  }
149
183
  // Cache defeater (known; not fixed here). Emptying the tool set changes the
150
184
  // very front of the request prefix — tool definitions are cached ahead of
@@ -166,11 +200,13 @@ export async function runAgent(agent, initial, session, opts) {
166
200
  markLastLoopAssistant(messages, initial.length);
167
201
  await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
168
202
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
203
+ logFinish("warn", "aborted", true);
169
204
  return { messages, finish: { reason: "aborted", cappedOut: true, truncationRecoveries } };
170
205
  }
171
206
  messages.push(wrapUp.message);
172
207
  await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
173
208
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
209
+ logFinish("warn", "max_iterations", true);
174
210
  return { messages, finish: { reason: "max_iterations", cappedOut: true, truncationRecoveries } };
175
211
  }
176
212
  function toolCallParts(message) {
@@ -256,8 +292,14 @@ async function execute(tu, tool, input, ctx, isFatalLoopError) {
256
292
  return errorResult(tu, toolErrorContent(err));
257
293
  }
258
294
  }
295
+ /**
296
+ * Normalize a tool's return value to plain JSON, stripping NUL from every string
297
+ * it holds ({@link stripNulCharacters}). The reviver sees values, not keys — a
298
+ * NUL in an object KEY would still reach the row, but keys come from tool result
299
+ * types rather than scanned bytes, so nothing produces one.
300
+ */
259
301
  function jsonValue(value) {
260
- return JSON.parse(JSON.stringify(value ?? null));
302
+ return JSON.parse(JSON.stringify(value ?? null), (_key, v) => (typeof v === "string" ? stripNulCharacters(v) : v));
261
303
  }
262
304
  function successResult(toolCall, value) {
263
305
  return {
@@ -267,12 +309,15 @@ function successResult(toolCall, value) {
267
309
  output: { type: "json", value: jsonValue(value) },
268
310
  };
269
311
  }
312
+ // NUL is stripped from the prose BEFORE it is serialized: past the stringify it
313
+ // is the six-character escape \u0000 — legitimate JSON text, and no longer
314
+ // distinguishable from an error that happens to quote that escape.
270
315
  function toolErrorContent(value) {
271
316
  if (isToolError(value)) {
272
- return JSON.stringify({ error: value.error, retryable: value.retryable });
317
+ return JSON.stringify({ error: stripNulCharacters(value.error), retryable: value.retryable });
273
318
  }
274
319
  const { retryable } = classifyProviderError(value);
275
- const error = value instanceof Error ? value.message : String(value);
320
+ const error = stripNulCharacters(value instanceof Error ? value.message : String(value));
276
321
  return JSON.stringify({ error, retryable });
277
322
  }
278
323
  function errorResult(toolCall, content) {
@@ -280,7 +325,8 @@ function errorResult(toolCall, content) {
280
325
  type: "tool-result",
281
326
  toolCallId: toolCall.toolCallId,
282
327
  toolName: toolCall.toolName,
283
- output: { type: "error-text", value: content },
328
+ // A thrown tool error routinely quotes stderr verbatim — as exposed to NUL as a success payload.
329
+ output: { type: "error-text", value: stripNulCharacters(content) },
284
330
  };
285
331
  }
286
332
  function isAskRejected(err) {
@@ -28,8 +28,6 @@ import type { AgentDefinition, LoopMessage } from "./types.js";
28
28
  export declare const DEFAULT_SALVAGE_ITERATIONS = 3;
29
29
  /** Describes how to salvage a run that never reached its terminal tool. */
30
30
  export interface TerminalSalvage {
31
- /** True once the agent recorded its terminal outcome (reads the closure cell). */
32
- readonly resolved: () => boolean;
33
31
  /** Terminal tools offered on the salvage turn (submit / blocker / …). Must be
34
32
  * the same instances the first run used — they close over the outcome cell. */
35
33
  readonly tools: readonly Tool[];
@@ -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,8 +37,14 @@ 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
+ if (opts.resolved?.() || opts.signal.aborted)
40
41
  return first;
42
+ // Reported here rather than in `runAgent` because the loop cannot know it is being
43
+ // salvaged: it sees an ordinary run with a small budget and a restricted tool set.
44
+ // Only this wrapper holds the fact that a first attempt ended without its outcome.
45
+ (opts.logger ?? createNoopLogger())
46
+ .named("loop")
47
+ .warn("salvaging a run that never reached its terminal tool", { agentId: agent.id, callPath: session.provenance.callPath });
41
48
  const salvageAgent = {
42
49
  ...agent,
43
50
  tools: [...salvage.tools],
@@ -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;