@inflexa-ai/harness 0.39.1 → 0.40.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 (191) hide show
  1. package/README.md +2 -3
  2. package/dist/agents/conversation-agent.d.ts +3 -0
  3. package/dist/agents/conversation-agent.js +13 -2
  4. package/dist/agents/report-session-agent.d.ts +3 -0
  5. package/dist/agents/report-session-agent.js +2 -0
  6. package/dist/agents/sandbox/cheminformatics-agent.js +0 -3
  7. package/dist/agents/sandbox/shared.d.ts +11 -2
  8. package/dist/agents/sandbox/shared.js +4 -0
  9. package/dist/app/chat-turn.d.ts +75 -12
  10. package/dist/app/chat-turn.js +149 -21
  11. package/dist/app/message-assembly.d.ts +16 -52
  12. package/dist/app/message-assembly.js +41 -68
  13. package/dist/app/run-activity.d.ts +4 -6
  14. package/dist/app/run-activity.js +7 -27
  15. package/dist/app/spawn-report-session.d.ts +6 -11
  16. package/dist/app/spawn-report-session.js +5 -6
  17. package/dist/app/synthesize-run.d.ts +3 -0
  18. package/dist/app/synthesize-run.js +1 -0
  19. package/dist/billing/usage-recorder.d.ts +3 -4
  20. package/dist/billing/usage-recorder.js +3 -4
  21. package/dist/contracts/chat-parts.d.ts +11 -1
  22. package/dist/contracts/chat-parts.js +1 -0
  23. package/dist/contracts/index.d.ts +1 -1
  24. package/dist/contracts/part-registry.js +1 -0
  25. package/dist/contracts/report-blocks.d.ts +8 -8
  26. package/dist/contracts/schemas/chat-parts.d.ts +32 -9
  27. package/dist/contracts/schemas/chat-parts.js +10 -0
  28. package/dist/execution/artifact-metadata.d.ts +26 -33
  29. package/dist/execution/artifact-metadata.js +44 -106
  30. package/dist/execution/artifact-registration.d.ts +12 -2
  31. package/dist/execution/artifact-registration.js +9 -4
  32. package/dist/execution/post-step-pipeline.d.ts +26 -22
  33. package/dist/execution/post-step-pipeline.js +30 -24
  34. package/dist/execution/run-synthesis.d.ts +3 -0
  35. package/dist/execution/run-synthesis.js +11 -1
  36. package/dist/execution/step-summary.d.ts +21 -30
  37. package/dist/execution/step-summary.js +30 -57
  38. package/dist/index.d.ts +11 -10
  39. package/dist/index.js +16 -35
  40. package/dist/lib/zod-issues.js +2 -4
  41. package/dist/loop/compaction.d.ts +20 -0
  42. package/dist/loop/compaction.js +2 -0
  43. package/dist/loop/continue-agent.d.ts +40 -0
  44. package/dist/loop/continue-agent.js +38 -0
  45. package/dist/loop/metrics.d.ts +30 -22
  46. package/dist/loop/metrics.js +51 -22
  47. package/dist/loop/run-agent.d.ts +111 -13
  48. package/dist/loop/run-agent.js +467 -220
  49. package/dist/loop/run-to-terminal.d.ts +16 -17
  50. package/dist/loop/run-to-terminal.js +24 -35
  51. package/dist/loop/tool-mask.d.ts +23 -0
  52. package/dist/loop/tool-mask.js +46 -0
  53. package/dist/loop/tool-output.d.ts +48 -0
  54. package/dist/loop/tool-output.js +79 -0
  55. package/dist/loop/types.d.ts +6 -3
  56. package/dist/memory/ai-sdk-message-storage.d.ts +49 -2
  57. package/dist/memory/ai-sdk-message-storage.js +78 -2
  58. package/dist/memory/conversation-display-recorder.d.ts +6 -0
  59. package/dist/memory/conversation-display-recorder.js +64 -22
  60. package/dist/memory/conversation-display-replay.d.ts +5 -14
  61. package/dist/memory/conversation-display-replay.js +66 -32
  62. package/dist/memory/conversation-display-storage.d.ts +2 -1
  63. package/dist/memory/conversation-display-storage.js +2 -1
  64. package/dist/memory/conversation-view.d.ts +53 -0
  65. package/dist/memory/conversation-view.js +130 -0
  66. package/dist/memory/count-tokens.d.ts +5 -7
  67. package/dist/memory/count-tokens.js +7 -9
  68. package/dist/memory/thread-history.d.ts +74 -102
  69. package/dist/memory/thread-history.js +181 -246
  70. package/dist/memory/thread-store.d.ts +3 -3
  71. package/dist/memory/thread-store.js +7 -2
  72. package/dist/memory/tool-call-integrity.d.ts +15 -21
  73. package/dist/memory/tool-call-integrity.js +31 -38
  74. package/dist/memory/working-memory.d.ts +2 -5
  75. package/dist/memory/working-memory.js +2 -5
  76. package/dist/prompts/SOUL.d.ts +2 -2
  77. package/dist/prompts/SOUL.js +2 -7
  78. package/dist/prompts/analogical-reasoner.d.ts +1 -1
  79. package/dist/prompts/analogical-reasoner.js +16 -32
  80. package/dist/prompts/briefing.js +1 -1
  81. package/dist/prompts/compaction.d.ts +4 -0
  82. package/dist/prompts/compaction.js +24 -0
  83. package/dist/prompts/conversation.d.ts +1 -1
  84. package/dist/prompts/conversation.js +26 -35
  85. package/dist/prompts/execute-analysis/step-summary.js +7 -5
  86. package/dist/prompts/literature-reviewer.d.ts +1 -1
  87. package/dist/prompts/literature-reviewer.js +29 -34
  88. package/dist/prompts/planner.js +15 -29
  89. package/dist/prompts/report-session.d.ts +1 -1
  90. package/dist/prompts/report-session.js +2 -33
  91. package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
  92. package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +1 -2
  93. package/dist/prompts/sandbox/cheminformatics-agent.d.ts +1 -1
  94. package/dist/prompts/sandbox/cheminformatics-agent.js +10 -29
  95. package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
  96. package/dist/prompts/sandbox/chromatin-agent.js +1 -2
  97. package/dist/prompts/sandbox/data-profiler.js +6 -3
  98. package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
  99. package/dist/prompts/sandbox/dna-methylation-agent.js +2 -7
  100. package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
  101. package/dist/prompts/sandbox/drug-repurposing-agent.js +20 -24
  102. package/dist/prompts/sandbox/enrichment-agent.d.ts +1 -1
  103. package/dist/prompts/sandbox/enrichment-agent.js +3 -2
  104. package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
  105. package/dist/prompts/sandbox/genomic-variant-agent.js +0 -2
  106. package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
  107. package/dist/prompts/sandbox/immune-profiling-agent.js +7 -14
  108. package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
  109. package/dist/prompts/sandbox/metabolomics-agent.js +0 -2
  110. package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
  111. package/dist/prompts/sandbox/microbiome-agent.js +14 -21
  112. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  113. package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -19
  114. package/dist/prompts/sandbox/multimodal-sc-agent.d.ts +1 -1
  115. package/dist/prompts/sandbox/multimodal-sc-agent.js +4 -5
  116. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  117. package/dist/prompts/sandbox/network-agent.js +7 -8
  118. package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
  119. package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +19 -19
  120. package/dist/prompts/sandbox/proteomics-agent.d.ts +1 -1
  121. package/dist/prompts/sandbox/proteomics-agent.js +3 -4
  122. package/dist/prompts/sandbox/scientific-executor.d.ts +1 -1
  123. package/dist/prompts/sandbox/scientific-executor.js +7 -10
  124. package/dist/prompts/sandbox/single-cell-agent.d.ts +1 -1
  125. package/dist/prompts/sandbox/single-cell-agent.js +2 -4
  126. package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
  127. package/dist/prompts/sandbox/spatial-omics-agent.js +3 -5
  128. package/dist/prompts/sandbox/statistical-modeling-agent.d.ts +1 -1
  129. package/dist/prompts/sandbox/statistical-modeling-agent.js +1 -1
  130. package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
  131. package/dist/prompts/sandbox/translational-safety-agent.js +10 -19
  132. package/dist/prompts/sandbox-standards.d.ts +7 -7
  133. package/dist/prompts/sandbox-standards.js +74 -76
  134. package/dist/prompts/synthesis-agent.d.ts +1 -1
  135. package/dist/prompts/synthesis-agent.js +16 -42
  136. package/dist/providers/ai-sdk.d.ts +39 -6
  137. package/dist/providers/ai-sdk.js +117 -18
  138. package/dist/providers/errors.d.ts +2 -0
  139. package/dist/providers/errors.js +5 -0
  140. package/dist/providers/prompt-cache.d.ts +43 -41
  141. package/dist/providers/prompt-cache.js +47 -41
  142. package/dist/providers/reasoning.d.ts +10 -21
  143. package/dist/providers/reasoning.js +10 -21
  144. package/dist/providers/types.d.ts +21 -5
  145. package/dist/report-model/authoring-grammar.d.ts +2 -2
  146. package/dist/report-model/pin-snapshot.js +4 -10
  147. package/dist/runtime/assemble.d.ts +9 -7
  148. package/dist/runtime/assemble.js +7 -3
  149. package/dist/sandbox/create-sandbox.d.ts +2 -2
  150. package/dist/sandbox/submit-exec.d.ts +1 -1
  151. package/dist/schemas/artifact-manifest.d.ts +4 -4
  152. package/dist/state/artifacts.d.ts +11 -9
  153. package/dist/state/artifacts.js +112 -93
  154. package/dist/state/data-profile.d.ts +2 -2
  155. package/dist/state/data-profile.js +2 -2
  156. package/dist/state/init.js +39 -4
  157. package/dist/state/purge-analysis.js +4 -0
  158. package/dist/state/tool-outputs.d.ts +4 -0
  159. package/dist/state/tool-outputs.js +30 -0
  160. package/dist/tasks/data-profile.d.ts +31 -10
  161. package/dist/tasks/data-profile.js +116 -95
  162. package/dist/tools/ad-hoc-router.d.ts +8 -0
  163. package/dist/tools/ad-hoc-router.js +21 -2
  164. package/dist/tools/display/show-user.d.ts +1 -1
  165. package/dist/tools/execute-analysis.d.ts +3 -0
  166. package/dist/tools/execute-analysis.js +9 -1
  167. package/dist/tools/read-tool-output.d.ts +7 -0
  168. package/dist/tools/read-tool-output.js +130 -0
  169. package/dist/tools/research/generate-analogy-report.d.ts +10 -26
  170. package/dist/tools/research/generate-analogy-report.js +76 -163
  171. package/dist/tools/research/generate-plan.d.ts +3 -0
  172. package/dist/tools/research/generate-plan.js +4 -1
  173. package/dist/tools/research/literature-reviewer.d.ts +3 -0
  174. package/dist/tools/research/literature-reviewer.js +3 -0
  175. package/dist/tools/sandbox/report-blocker.d.ts +18 -5
  176. package/dist/tools/sandbox/report-blocker.js +54 -6
  177. package/dist/tools/sandbox/submit-file-metadata.d.ts +30 -0
  178. package/dist/tools/sandbox/submit-file-metadata.js +67 -0
  179. package/dist/tools/start-report-session.d.ts +1 -3
  180. package/dist/tools/start-report-session.js +0 -2
  181. package/dist/tools/workspace/execute-command.js +7 -4
  182. package/dist/tools/workspace/result-bounds.d.ts +4 -4
  183. package/dist/tools/workspace/result-bounds.js +6 -5
  184. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  185. package/dist/workflows/execute-analysis.d.ts +3 -0
  186. package/dist/workflows/execute-analysis.js +6 -3
  187. package/dist/workflows/sandbox-step.d.ts +30 -9
  188. package/dist/workflows/sandbox-step.js +37 -8
  189. package/package.json +9 -9
  190. package/dist/providers/anthropic.d.ts +0 -25
  191. package/dist/providers/anthropic.js +0 -28
@@ -225,9 +225,9 @@ export function expireStaleDataProfile(pool, analysisId, timeoutMs) {
225
225
  * Reset a ledger row wedged at `running` with no workflow behind it back to
226
226
  * `failed`, so the normal retry path can re-profile it.
227
227
  *
228
- * A start that rejects after the CAS already flipped the row to `running`
228
+ * A start that fails after the CAS already flipped the row to `running`
229
229
  * compensates itself (`triggerDataProfile`/`runDataProfile` fail the ledger in
230
- * their catch). This covers the residual case that compensation cannot: a host
230
+ * their dispatch). This covers the residual case that compensation cannot: a host
231
231
  * that dies in the window between the CAS and the `DBOS.startWorkflow` insert
232
232
  * leaves a `running` row with no workflow for recovery to resume — nothing
233
233
  * would ever move it off `running`, and every later trigger reports
@@ -242,8 +242,8 @@ CREATE INDEX IF NOT EXISTS idx_cortex_regulatory_chunks_metadata
242
242
  -- the JSON operators retractLastTurn uses work the same on either type.
243
243
  --
244
244
  -- reported_usage holds a whole turn's TokenUsageRollup — what providers reported
245
- -- for the turn this row completed — and is written only on the LAST assistant row
246
- -- of a turn, so a reloaded transcript can show the figure the live turn showed.
245
+ -- for the turn this row completed — on the LAST assistant row of an older turn
246
+ -- only. cortex_thread_turns holds the figures of each later turn.
247
247
  -- It is NOT the tokens column beside it, and the two must never be read for
248
248
  -- each other: tokens is an offline js-tiktoken estimate stamped on EVERY row so
249
249
  -- the read path can window by budget without a provider round trip, and it exists
@@ -283,8 +283,8 @@ CREATE TABLE IF NOT EXISTS messages (
283
283
  role TEXT,
284
284
  content_jsonb JSONB,
285
285
  message_envelope JSON,
286
- -- Versioned AI SDK UIMessage projection for the whole turn. Present only
287
- -- on the genuine-user-start row. Model-history reads never select it.
286
+ -- Versioned AI SDK UIMessage projection of one written group, on the first
287
+ -- row of that group. Model-history reads never select it.
288
288
  display_envelope JSONB,
289
289
  tokens INTEGER NOT NULL,
290
290
  reported_usage JSONB,
@@ -294,6 +294,24 @@ CREATE TABLE IF NOT EXISTS messages (
294
294
  PRIMARY KEY (thread_id, seq)
295
295
  );
296
296
 
297
+ -- The state of one chat turn, and no message: a close never changes a row of
298
+ -- messages. messages.reported_usage and messages.turn_duration_ms hold the
299
+ -- figures of the older turns, which have no record here.
300
+ CREATE TABLE IF NOT EXISTS cortex_thread_turns (
301
+ thread_id TEXT NOT NULL,
302
+ -- The seq of the user row that opens the turn.
303
+ start_seq BIGINT NOT NULL,
304
+ status TEXT NOT NULL CHECK (status IN ('open', 'done', 'aborted', 'failed')),
305
+ -- A short text from the class of the error, never its message, because each later turn sends the note.
306
+ reason TEXT,
307
+ -- Absent when no provider reported a quantity, the same as messages.reported_usage.
308
+ reported_usage JSONB,
309
+ turn_duration_ms BIGINT,
310
+ opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
311
+ closed_at TIMESTAMPTZ,
312
+ PRIMARY KEY (thread_id, start_seq)
313
+ );
314
+
297
315
  -- Conversation thread metadata — the harness ThreadStore module's table,
298
316
  -- one row per conversation thread. Holds the analysis-scope, title, and
299
317
  -- soft-delete tombstone the harness messages table deliberately lacks.
@@ -413,6 +431,23 @@ CREATE TABLE IF NOT EXISTS cortex_ask_grants (
413
431
  created_at TEXT NOT NULL,
414
432
  PRIMARY KEY (analysis_id, user_id, grant_key)
415
433
  );
434
+
435
+ -- The kept text of each cut tool result. thread_id is null for a text of a run, thus purgeThread removes
436
+ -- only the texts of chat turns, and purgeAnalysis removes each row by analysis_id.
437
+ CREATE TABLE IF NOT EXISTS cortex_tool_outputs (
438
+ analysis_id TEXT NOT NULL,
439
+ ref TEXT NOT NULL,
440
+ tool_name TEXT NOT NULL,
441
+ tool_call_id TEXT NOT NULL,
442
+ thread_id TEXT,
443
+ content TEXT NOT NULL,
444
+ total_length INTEGER NOT NULL,
445
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
446
+ PRIMARY KEY (analysis_id, ref)
447
+ );
448
+
449
+ CREATE INDEX IF NOT EXISTS cortex_tool_outputs_thread_idx
450
+ ON cortex_tool_outputs (thread_id) WHERE thread_id IS NOT NULL;
416
451
  `;
417
452
  /**
418
453
  * Initialize Cortex state tables. Safe to call on every startup and across
@@ -78,6 +78,8 @@ const ANALYSIS_KEYED_DELETES = [
78
78
  { op: "purgeAnalysis.workingMemory", sql: "DELETE FROM cortex_working_memory WHERE analysis_id = $1" },
79
79
  { op: "purgeAnalysis.asks", sql: "DELETE FROM cortex_asks WHERE analysis_id = $1" },
80
80
  { op: "purgeAnalysis.askGrants", sql: "DELETE FROM cortex_ask_grants WHERE analysis_id = $1" },
81
+ // By its own analysis id, not through the thread rows: a kept text of a run names no thread.
82
+ { op: "purgeAnalysis.toolOutputs", sql: "DELETE FROM cortex_tool_outputs WHERE analysis_id = $1" },
81
83
  { op: "purgeAnalysis.plans", sql: "DELETE FROM cortex_plans WHERE analysis_id = $1" },
82
84
  { op: "purgeAnalysis.reportVersions", sql: "DELETE FROM cortex_report_versions WHERE analysis_id = $1" },
83
85
  { op: "purgeAnalysis.reportSessionState", sql: "DELETE FROM cortex_report_session_state WHERE analysis_id = $1" },
@@ -133,6 +135,8 @@ export function createAnalysisPurge({ pool, workflows, logger: injected }) {
133
135
  // thread is still the analysis's, and a purge takes it too.
134
136
  deleteRows(client, "purgeAnalysis.messages", `DELETE FROM messages
135
137
  WHERE thread_id IN (SELECT thread_id FROM cortex_analysis_threads WHERE analysis_id = $1)`, analysisId)
138
+ .andThen((messages) => deleteRows(client, "purgeAnalysis.turnRecords", `DELETE FROM cortex_thread_turns
139
+ WHERE thread_id IN (SELECT thread_id FROM cortex_analysis_threads WHERE analysis_id = $1)`, analysisId).map(() => messages))
136
140
  .andThen((messages) => deleteRows(client, "purgeAnalysis.threads", "DELETE FROM cortex_analysis_threads WHERE analysis_id = $1", analysisId).map((threads) => ({
137
141
  threads,
138
142
  messages,
@@ -0,0 +1,4 @@
1
+ /** The Postgres realization of the tool output store of the loop, over `cortex_tool_outputs`. */
2
+ import type { Pool } from "pg";
3
+ import type { ToolOutputStore } from "../loop/tool-output.js";
4
+ export declare function createToolOutputStore(pool: Pool): ToolOutputStore;
@@ -0,0 +1,30 @@
1
+ /** The Postgres realization of the tool output store of the loop, over `cortex_tool_outputs`. */
2
+ import { tryMutation, tryQuery } from "../lib/db-result.js";
3
+ function toKeptToolOutput(row) {
4
+ return {
5
+ analysisId: row.analysis_id,
6
+ ref: row.ref,
7
+ toolName: row.tool_name,
8
+ toolCallId: row.tool_call_id,
9
+ ...(row.thread_id === null ? {} : { threadId: row.thread_id }),
10
+ content: row.content,
11
+ totalLength: row.total_length,
12
+ };
13
+ }
14
+ export function createToolOutputStore(pool) {
15
+ return {
16
+ put: (output) => tryMutation("toolOutputs.put", () => pool.query(
17
+ // The update sets no `created_at`, thus the row keeps the time of its first put.
18
+ `INSERT INTO cortex_tool_outputs (analysis_id, ref, tool_name, tool_call_id, thread_id, content, total_length)
19
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
20
+ ON CONFLICT (analysis_id, ref) DO UPDATE SET
21
+ tool_name = EXCLUDED.tool_name,
22
+ tool_call_id = EXCLUDED.tool_call_id,
23
+ thread_id = EXCLUDED.thread_id,
24
+ content = EXCLUDED.content,
25
+ total_length = EXCLUDED.total_length`, [output.analysisId, output.ref, output.toolName, output.toolCallId, output.threadId ?? null, output.content, output.totalLength])).map(() => undefined),
26
+ get: (analysisId, ref) => tryQuery("toolOutputs.get", () => pool.query(`SELECT analysis_id, ref, tool_name, tool_call_id, thread_id, content, total_length
27
+ FROM cortex_tool_outputs
28
+ WHERE analysis_id = $1 AND ref = $2`, [analysisId, ref])).map(({ rows }) => (rows[0] === undefined ? null : toKeptToolOutput(rows[0]))),
29
+ };
30
+ }
@@ -17,7 +17,7 @@
17
17
  * triggers dedup via the ledger CAS (only one `tryStart/tryRerun/tryRetry`
18
18
  * UPDATE wins); each winning attempt starts a workflow under a per-attempt id.
19
19
  */
20
- import { type ResultAsync } from "neverthrow";
20
+ import { ResultAsync } from "neverthrow";
21
21
  import type { Pool } from "pg";
22
22
  import { type AuthContext, type RunSession } from "../auth/types.js";
23
23
  import type { EnvironmentStorePaths } from "../config/environment-stores.js";
@@ -26,6 +26,8 @@ import type { RunAuthorization, RunAuthorizer } from "../execution/run-authorize
26
26
  import type { StagedInput } from "../execution/staged-input.js";
27
27
  import type { SandboxAgentDeps } from "../agents/sandbox/shared.js";
28
28
  import type { BioToolKeys } from "../tools/bio/keys.js";
29
+ import type { ToolOutputStore } from "../loop/tool-output.js";
30
+ import type { DbError } from "../lib/db-result.js";
29
31
  import { type GateRefusal } from "../lib/hooks.js";
30
32
  import type { Logger } from "../lib/logger.js";
31
33
  import type { UsageRecorder } from "../billing/usage-recorder.js";
@@ -62,6 +64,8 @@ export interface DataProfileDeps extends EnvironmentStorePaths {
62
64
  readonly skillsDir: string;
63
65
  /** LLM usage-accounting seam for the profiler agent loop; omitted falls back to the no-op recorder. */
64
66
  readonly usageRecorder?: UsageRecorder;
67
+ /** The store of the profiler loop and of its read tool. */
68
+ readonly toolOutputStore?: ToolOutputStore;
65
69
  /**
66
70
  * The farm-extension seam of the embedder. Bound, it rides into the
67
71
  * profiler agent deps, and the substrate attaches `link_packages`. Thus
@@ -149,9 +153,25 @@ export declare function runDataProfileBody(input: DataProfileWorkflowInput, deps
149
153
  * - `"already_running"`: a workflow already owns the row, and nothing new ran.
150
154
  * - `"completed"`: the analysis has no input files, so the row is `completed` at once
151
155
  * with no result. No workflow ran (see {@link completeEmptyDataProfile}).
152
- * - `"failed"`: the trigger refused the call or faulted, and the row is unchanged.
156
+ * - `"failed"`: the trigger refused the call, and the row is unchanged.
153
157
  */
154
158
  export type DataProfileTriggerResult = "started" | "restarted" | "already_running" | "completed" | "failed";
159
+ /**
160
+ * Why the dispatch of a claimed profile started no workflow. The dispatch writes `failed` to the claimed
161
+ * row before a caller reads the `err`:
162
+ * - `refused`: `RunAuthorizer.authorize` gave an `err`. `reason` and `suspend` are the values of the host.
163
+ * - `start_failed`: DBOS did not start the workflow. `cause` is the throw of DBOS.
164
+ */
165
+ export type DataProfileStartError = {
166
+ readonly type: "refused";
167
+ readonly reason: string;
168
+ readonly suspend: boolean;
169
+ } | {
170
+ readonly type: "start_failed";
171
+ readonly cause: unknown;
172
+ };
173
+ /** A one-line, user-facing description of a `DataProfileStartError`. */
174
+ export declare function describeDataProfileStartError(e: DataProfileStartError): string;
155
175
  /**
156
176
  * Route-side deps for triggering the data-profile workflow: the ledger pool,
157
177
  * the run authorizer, and the registered workflow callable. The body's
@@ -213,16 +233,17 @@ export declare function dataProfileWorkflowId(analysisId: string, nonce: string)
213
233
  * `"completed"`. An empty manifest against a seed that names files is a divergence
214
234
  * between the caller and the ledger, and the trigger refuses it.
215
235
  *
216
- * Returns what happened, so the caller can surface it (e.g. in the seed response).
236
+ * Returns what happened, so the caller can surface it (e.g. in the seed response). A
237
+ * failed ledger read or claim is the `err`. The dispatch settles its own row, thus a
238
+ * refusal or a failed start after a claim shows on the ledger only.
217
239
  */
218
- export declare function triggerDataProfile(deps: DataProfileTriggerDeps, params: DataProfileTriggerParams): Promise<DataProfileTriggerResult>;
240
+ export declare function triggerDataProfile(deps: DataProfileTriggerDeps, params: DataProfileTriggerParams): ResultAsync<DataProfileTriggerResult, DbError>;
219
241
  /**
220
242
  * Start the data-profile workflow for an already-claimed analysis (the retry
221
243
  * route claims via `tryRetryDataProfile`, then calls this). The caller does not
222
- * await the workflow's completion, but a start that rejects compensates the
223
- * ledger (see {@link compensateStartFailure}) before re-throwing, so a caller's
224
- * own `.catch` still observes the error and the row never wedges at `running`.
225
- * A refused authorization fails the row with the reason of the host, and the
226
- * promise resolves: the row is where a caller reads the outcome.
244
+ * wait for the completion of the workflow. A refused authorization and a failed
245
+ * start each settle the row as `failed` before the `err` (see
246
+ * {@link dispatchClaimedProfile}), thus a caller that minted an authorization
247
+ * can revoke it.
227
248
  */
228
- export declare function runDataProfile(deps: DataProfileTriggerDeps, params: DataProfileTriggerParams): Promise<void>;
249
+ export declare function runDataProfile(deps: DataProfileTriggerDeps, params: DataProfileTriggerParams): ResultAsync<void, DataProfileStartError>;
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
21
21
  import { randomUUID } from "node:crypto";
22
- import { err, ok } from "neverthrow";
22
+ import { ResultAsync, err, ok } from "neverthrow";
23
23
  import { forStep, forSubAgent } from "../auth/types.js";
24
24
  import { createDataProfilerAgent } from "../agents/sandbox/data-profiler.js";
25
25
  import { runToTerminal } from "../loop/run-to-terminal.js";
@@ -271,6 +271,7 @@ export function profilerSandboxAgentDeps(deps, step) {
271
271
  ...(deps.imagePackagesFile ? { imagePackagesFile: deps.imagePackagesFile } : {}),
272
272
  ...(deps.refStorePath ? { refStorePath: deps.refStorePath } : {}),
273
273
  ...(deps.extendAnalysisFarm ? { extendAnalysisFarm: deps.extendAnalysisFarm } : {}),
274
+ ...(deps.toolOutputStore ? { toolOutputStore: deps.toolOutputStore } : {}),
274
275
  bioKeys: deps.bioKeys,
275
276
  step,
276
277
  };
@@ -345,14 +346,14 @@ export async function runDataProfileBody(input, deps) {
345
346
  return;
346
347
  }
347
348
  // 2. Register staged files in cortex_artifacts
348
- await upsertArtifacts(deps.pool, stagedInputs.map((f) => ({
349
+ unwrapOrThrow(await upsertArtifacts(deps.pool, stagedInputs.map((f) => ({
349
350
  resourceId: analysisId,
350
351
  path: inputArtifactPath(f),
351
352
  hash: f.hash,
352
353
  size: f.size,
353
354
  role: "input",
354
355
  fileId: f.fileId,
355
- })));
356
+ }))));
356
357
  const executionId = generateExecutionId(DATA_PROFILE_AGENT_ID);
357
358
  const workflowId = DBOS.workflowID ?? `${DATA_PROFILE_RUN_LITERAL}:${executionId}`;
358
359
  const childSession = forSubAgent(runSession, DATA_PROFILE_AGENT_ID);
@@ -548,6 +549,7 @@ export async function runDataProfileBody(input, deps) {
548
549
  runStep: durableStep,
549
550
  resolved: () => accepted !== null,
550
551
  usageRecorder: deps.usageRecorder,
552
+ toolOutputStore: deps.toolOutputStore,
551
553
  isFatalLoopError: (err) => err instanceof DBOSErrors.DBOSWorkflowCancelledError,
552
554
  }, {
553
555
  tools: [submitProfileTool],
@@ -636,6 +638,15 @@ function settleTerminalWrite(logger, analysisId, write, status) {
636
638
  }
637
639
  recordRunCompleted({ workflow: "data_profile", status, durationMs: elapsedSinceIso(write.startedAt) });
638
640
  }
641
+ /** A one-line, user-facing description of a `DataProfileStartError`. */
642
+ export function describeDataProfileStartError(e) {
643
+ switch (e.type) {
644
+ case "refused":
645
+ return `the profile run was not authorized: ${e.reason}`;
646
+ case "start_failed":
647
+ return `the profile workflow did not start: ${profileFailureReason(e.cause)}`;
648
+ }
649
+ }
639
650
  /**
640
651
  * Resolve the `RunSession` the workflow runs under. Whether the authorization
641
652
  * is freshly minted (Cortex-owned) or reused (caller-owned) is decided by the
@@ -667,18 +678,14 @@ export function dataProfileWorkflowId(analysisId, nonce) {
667
678
  return `dataprofile:${analysisId}:${nonce}`;
668
679
  }
669
680
  /**
670
- * Authorize the run via the `RunAuthorizer` and start the data-profile workflow
671
- * under a per-attempt id `dataprofile:{analysisId}:{nonce}` — concurrent
672
- * triggers are already serialized by the ledger CAS. The caller has already
673
- * staged the inputs and supplied the manifest in `params.stagedInputs`; this
674
- * forwards it into the workflow input. Fire-and-forget: the handle result is
675
- * not awaited.
681
+ * Start the data-profile workflow under the authorization and a per-attempt id
682
+ * `dataprofile:{analysisId}:{nonce}` — concurrent triggers are already
683
+ * serialized by the ledger CAS. The caller has already staged the inputs and
684
+ * supplied the manifest in `params.stagedInputs`; this forwards it into the
685
+ * workflow input. The handle result is not awaited.
676
686
  */
677
- async function startDataProfileWorkflow(deps, params) {
678
- const authorized = await authorizeDataProfile(deps, params);
679
- if (authorized.isErr())
680
- return err(authorized.error);
681
- const { runSession, ownsMandate } = authorized.value; // oss-core-managed-ok
687
+ async function startDataProfileWorkflow(deps, params, authorization) {
688
+ const { runSession, ownsMandate } = authorization; // oss-core-managed-ok
682
689
  const attemptNonce = randomUUID();
683
690
  await DBOS.startWorkflow(deps.workflow, {
684
691
  workflowID: dataProfileWorkflowId(params.analysisId, attemptNonce),
@@ -688,20 +695,29 @@ async function startDataProfileWorkflow(deps, params) {
688
695
  ownsMandate, // oss-core-managed-ok
689
696
  stagedInputs: params.stagedInputs,
690
697
  });
691
- return ok(undefined);
692
698
  }
693
699
  /**
694
- * Dispatch a claimed profile, fire-and-forget. The claim already flipped the
695
- * row to `running`, thus a refused authorization and a rejected start each
696
- * settle the row as `failed`, and it never wedges at `running`.
700
+ * Authorize and start a claimed profile. The claim already flipped the row to
701
+ * `running`, thus a refused authorization and a failed start each settle the
702
+ * row as `failed` before the `err`, and the row never wedges at `running`. A
703
+ * rejected `authorize` promise is a defect of the host: the row is settled the
704
+ * same way, and the rejection passes through.
697
705
  */
698
706
  async function dispatchClaimedProfile(deps, params, phase) {
699
- const started = await startDataProfileWorkflow(deps, params).catch(async (cause) => {
700
- await compensateStartFailure(deps, params.analysisId, phase, cause);
701
- return undefined;
707
+ const authorized = await Promise.resolve(authorizeDataProfile(deps, params)).catch(async (defect) => {
708
+ await compensateStartFailure(deps, params.analysisId, phase, defect);
709
+ throw defect;
702
710
  });
703
- if (started?.isErr())
704
- await refuseClaimedProfile(deps, params.analysisId, phase, started.error);
711
+ if (authorized.isErr()) {
712
+ await refuseClaimedProfile(deps, params.analysisId, phase, authorized.error);
713
+ return err({ type: "refused", reason: authorized.error.reason, suspend: authorized.error.kind === "suspended" });
714
+ }
715
+ const started = await ResultAsync.fromPromise(startDataProfileWorkflow(deps, params, authorized.value), (cause) => cause);
716
+ if (started.isErr()) {
717
+ await compensateStartFailure(deps, params.analysisId, phase, started.error);
718
+ return err({ type: "start_failed", cause: started.error });
719
+ }
720
+ return ok(undefined);
705
721
  }
706
722
  /**
707
723
  * Attempt to claim and run data profiling for an analysis, then start the workflow
@@ -720,70 +736,85 @@ async function dispatchClaimedProfile(deps, params, phase) {
720
736
  * `"completed"`. An empty manifest against a seed that names files is a divergence
721
737
  * between the caller and the ledger, and the trigger refuses it.
722
738
  *
723
- * Returns what happened, so the caller can surface it (e.g. in the seed response).
739
+ * Returns what happened, so the caller can surface it (e.g. in the seed response). A
740
+ * failed ledger read or claim is the `err`. The dispatch settles its own row, thus a
741
+ * refusal or a failed start after a claim shows on the ledger only.
724
742
  */
725
- export async function triggerDataProfile(deps, params) {
743
+ export function triggerDataProfile(deps, params) {
744
+ return new ResultAsync(claimAndDispatchProfile(deps, params));
745
+ }
746
+ async function claimAndDispatchProfile(deps, params) {
726
747
  const logger = (deps.logger ?? createNoopLogger()).named("data-profile").with({ analysisId: params.analysisId });
727
748
  const { analysisId } = params;
728
- try {
729
- // ADVISORY pre-check, not the enforcement — the claim CAS carries the same seed
730
- // conjunct (see `SEEDED` in state/data-profile.ts) and is what actually makes a
731
- // seedless `running` row impossible. This read exists only to name the cause: on
732
- // the ordinary non-racing path it turns an opaque "no claim matched" into a
733
- // precise reason. An empty array is not a seed — it names zero files. Reads via
734
- // `loadSeedInputFileIds`, which returns null for BOTH a missing analysis row and
735
- // a NULL-seed row (`loadDataProfileStatus` would hide the seed of a cleared row).
736
- const seeded = unwrapOrThrow(await loadSeedInputFileIds(deps.pool, analysisId));
737
- if (params.stagedInputs.length === 0) {
738
- // The trigger validates the manifest it is about to dispatch against the ledger
739
- // seed. A seeded row profiled against an EMPTY manifest would claim `running` and
740
- // then hit the body's empty-manifest path, and complete with a NULL result while
741
- // the seed still names files. Refuse the divergence before any claim.
742
- if (seeded !== null && seeded.length > 0) {
743
- logger.error("trigger rejected: empty manifest dispatched against a non-empty seed", { seededCount: seeded.length });
744
- return "failed";
745
- }
746
- // The analysis has no input files, so there is nothing to profile. The row is
747
- // `completed` at once, with no workflow. The CAS carries the same seed
748
- // predicate as this pre-read, so a seed upsert that lands in between cannot be
749
- // hidden behind a finished profile: the stamp refuses, and the status read
750
- // below names the cause.
751
- if (unwrapOrThrow(await completeEmptyDataProfile(deps.pool, analysisId)))
752
- return "completed";
753
- const current = unwrapOrThrow(await loadDataProfileStatus(deps.pool, analysisId));
754
- if (current?.status === "running")
755
- return "already_running";
756
- logger.error("trigger rejected: empty-set completion refused (no analysis row, or the seed now names files)");
757
- return "failed";
758
- }
759
- // A non-empty manifest against a seed that names no file: the caller skipped the
760
- // seed, or the analysis row is missing. Name the cause before any claim.
761
- if (seeded === null || seeded.length === 0) {
762
- logger.error("trigger rejected: no seeded input set (missing analysis row or caller skipped seeding)");
763
- return "failed";
764
- }
765
- const started = unwrapOrThrow(await tryStartDataProfile(deps.pool, analysisId));
766
- if (started) {
767
- void dispatchClaimedProfile(deps, params, "Run");
768
- return "started";
749
+ // ADVISORY pre-check, not the enforcement — the claim CAS carries the same seed
750
+ // conjunct (see `SEEDED` in state/data-profile.ts) and is what actually makes a
751
+ // seedless `running` row impossible. This read exists only to name the cause: on
752
+ // the ordinary non-racing path it turns an opaque "no claim matched" into a
753
+ // precise reason. An empty array is not a seed — it names zero files. Reads via
754
+ // `loadSeedInputFileIds`, which returns null for BOTH a missing analysis row and
755
+ // a NULL-seed row (`loadDataProfileStatus` would hide the seed of a cleared row).
756
+ const seedRead = await loadSeedInputFileIds(deps.pool, analysisId);
757
+ if (seedRead.isErr())
758
+ return err(seedRead.error);
759
+ const seeded = seedRead.value;
760
+ if (params.stagedInputs.length === 0) {
761
+ // The trigger validates the manifest it is about to dispatch against the ledger
762
+ // seed. A seeded row profiled against an EMPTY manifest would claim `running` and
763
+ // then hit the body's empty-manifest path, and complete with a NULL result while
764
+ // the seed still names files. Refuse the divergence before any claim.
765
+ if (seeded !== null && seeded.length > 0) {
766
+ logger.error("trigger rejected: empty manifest dispatched against a non-empty seed", { seededCount: seeded.length });
767
+ return ok("failed");
769
768
  }
770
- const restarted = unwrapOrThrow(await tryRerunDataProfile(deps.pool, analysisId));
771
- if (restarted) {
772
- void dispatchClaimedProfile(deps, params, "Re-run");
773
- return "restarted";
774
- }
775
- const status = unwrapOrThrow(await loadDataProfileStatus(deps.pool, analysisId));
776
- if (status?.status === "running")
777
- return "already_running";
778
- return "failed";
769
+ // The analysis has no input files, so there is nothing to profile. The row is
770
+ // `completed` at once, with no workflow. The CAS carries the same seed
771
+ // predicate as this pre-read, so a seed upsert that lands in between cannot be
772
+ // hidden behind a finished profile: the stamp refuses, and the status read
773
+ // below names the cause.
774
+ const completed = await completeEmptyDataProfile(deps.pool, analysisId);
775
+ if (completed.isErr())
776
+ return err(completed.error);
777
+ if (completed.value)
778
+ return ok("completed");
779
+ const current = await loadDataProfileStatus(deps.pool, analysisId);
780
+ if (current.isErr())
781
+ return err(current.error);
782
+ if (current.value?.status === "running")
783
+ return ok("already_running");
784
+ logger.error("trigger rejected: empty-set completion refused (no analysis row, or the seed now names files)");
785
+ return ok("failed");
779
786
  }
780
- catch (err) {
781
- logger.error("trigger error", logger.errorFields(err));
782
- return "failed";
787
+ // A non-empty manifest against a seed that names no file: the caller skipped the
788
+ // seed, or the analysis row is missing. Name the cause before any claim.
789
+ if (seeded === null || seeded.length === 0) {
790
+ logger.error("trigger rejected: no seeded input set (missing analysis row or caller skipped seeding)");
791
+ return ok("failed");
792
+ }
793
+ const started = await tryStartDataProfile(deps.pool, analysisId);
794
+ if (started.isErr())
795
+ return err(started.error);
796
+ if (started.value) {
797
+ dispatchInBackground(deps, params, "Run");
798
+ return ok("started");
799
+ }
800
+ const restarted = await tryRerunDataProfile(deps.pool, analysisId);
801
+ if (restarted.isErr())
802
+ return err(restarted.error);
803
+ if (restarted.value) {
804
+ dispatchInBackground(deps, params, "Re-run");
805
+ return ok("restarted");
783
806
  }
807
+ const status = await loadDataProfileStatus(deps.pool, analysisId);
808
+ if (status.isErr())
809
+ return err(status.error);
810
+ return ok(status.value?.status === "running" ? "already_running" : "failed");
811
+ }
812
+ /** The dispatch settles the row on each arm and logs a defect, thus the trigger keeps no outcome of it. */
813
+ function dispatchInBackground(deps, params, phase) {
814
+ void dispatchClaimedProfile(deps, params, phase).catch(() => undefined);
784
815
  }
785
816
  /**
786
- * Compensate a fire-and-forget start that rejected. The ledger CAS in
817
+ * Compensate a start that failed after the claim. The ledger CAS in
787
818
  * `tryStart`/`tryRerun`/`tryRetry` already flipped the row to `running`; if the
788
819
  * dispatch never landed a workflow, DBOS has nothing to recover, so without
789
820
  * this the row would sit at `running` forever and every later trigger would
@@ -827,23 +858,13 @@ async function failClaimedProfile(deps, analysisId, phase, reason) {
827
858
  /**
828
859
  * Start the data-profile workflow for an already-claimed analysis (the retry
829
860
  * route claims via `tryRetryDataProfile`, then calls this). The caller does not
830
- * await the workflow's completion, but a start that rejects compensates the
831
- * ledger (see {@link compensateStartFailure}) before re-throwing, so a caller's
832
- * own `.catch` still observes the error and the row never wedges at `running`.
833
- * A refused authorization fails the row with the reason of the host, and the
834
- * promise resolves: the row is where a caller reads the outcome.
861
+ * wait for the completion of the workflow. A refused authorization and a failed
862
+ * start each settle the row as `failed` before the `err` (see
863
+ * {@link dispatchClaimedProfile}), thus a caller that minted an authorization
864
+ * can revoke it.
835
865
  */
836
- export async function runDataProfile(deps, params) {
837
- let started;
838
- try {
839
- started = await startDataProfileWorkflow(deps, params);
840
- }
841
- catch (cause) {
842
- await compensateStartFailure(deps, params.analysisId, "Retry", cause);
843
- throw cause;
844
- }
845
- if (started.isErr())
846
- await refuseClaimedProfile(deps, params.analysisId, "Retry", started.error);
866
+ export function runDataProfile(deps, params) {
867
+ return new ResultAsync(dispatchClaimedProfile(deps, params, "Retry"));
847
868
  }
848
869
  /**
849
870
  * Concise, UI-safe failure reason. `data_profile_error` is surfaced verbatim
@@ -1,7 +1,9 @@
1
1
  import type { Pool } from "pg";
2
2
  import { type AgentSession } from "../auth/types.js";
3
+ import type { UsageRecorder } from "../billing/usage-recorder.js";
3
4
  import type { ResourcePolicy, ResourceSpec } from "../config/resource-limits.js";
4
5
  import type { Logger } from "../lib/logger.js";
6
+ import { type AgentRunUsage } from "../loop/metrics.js";
5
7
  import { type ChatProvider } from "../providers/types.js";
6
8
  import type { CheckedPackage } from "./sandbox/list-available-packages.js";
7
9
  export declare const AD_HOC_ROUTER_AGENT_ID = "adhoc-router";
@@ -48,6 +50,8 @@ export interface AdHocRouterDeps {
48
50
  * the entries reach the link pass as the model wrote them.
49
51
  */
50
52
  readonly resolvePackages?: (names: readonly string[]) => Promise<readonly CheckedPackage[] | null>;
53
+ /** Falls back to the no-op recorder when omitted. */
54
+ readonly usageRecorder?: UsageRecorder;
51
55
  }
52
56
  export declare function defaultAdHocResources(policy?: ResourcePolicy): ResourceSpec;
53
57
  export declare function validAdHocResources(value: unknown, policy?: ResourcePolicy): ResourceSpec | null;
@@ -56,4 +60,8 @@ export declare function routeAdHocRequest(deps: AdHocRouterDeps, input: {
56
60
  request: string;
57
61
  session: AgentSession;
58
62
  signal: AbortSignal;
63
+ /** The router call folds its usage into this, so the turn total includes it. */
64
+ readonly turnUsage?: AgentRunUsage;
65
+ /** Carried into the record key of the router usage entry. */
66
+ readonly invocationId?: string;
59
67
  }): Promise<AdHocRoute>;
@@ -3,8 +3,11 @@ import { z } from "zod";
3
3
  import { PLANNABLE_AGENT_CATALOG } from "../agents/sandbox-catalog.js";
4
4
  import { forSubAgent } from "../auth/types.js";
5
5
  import { DATA_PROFILE_ORIENTATION_MAX_CHARS, buildDataProfileOrientation } from "../app/data-profile-orientation.js";
6
+ import { createNoopUsageRecorder } from "../billing/noop-usage-recorder.js";
6
7
  import { createNoopLogger } from "../lib/console-logger.js";
7
8
  import { unwrapOrThrow } from "../lib/result.js";
9
+ import { countChatTokens } from "../loop/metrics.js";
10
+ import { accountForChatCall } from "../loop/run-agent.js";
8
11
  import { packagesSection, resourceEstimationSection } from "../prompts/planner.js";
9
12
  import { effectiveDeadlineMs } from "../providers/types.js";
10
13
  import { formatQuery, parseQuery } from "../sandbox/package-identity.js";
@@ -12,6 +15,8 @@ import { loadDataProfileStatus } from "../state/index.js";
12
15
  export const AD_HOC_ROUTER_AGENT_ID = "adhoc-router";
13
16
  export const AD_HOC_ROUTER_TIMEOUT_MS = 10_000;
14
17
  export const AD_HOC_FALLBACK_AGENT_ID = "scientific-executor";
18
+ /** Must differ from a loop step name such as `llm-0`, since both share the usage-record key space. */
19
+ const AD_HOC_ROUTE_CALL_NAME = "adhoc-route";
15
20
  const resourcesSchema = z.object({
16
21
  cpu: z.number().positive(),
17
22
  memoryGb: z.number().positive(),
@@ -152,6 +157,7 @@ function routeTool() {
152
157
  }
153
158
  export async function routeAdHocRequest(deps, input) {
154
159
  const logger = (deps.logger ?? createNoopLogger()).named("adhoc-router").with({ analysisId: input.analysisId, model: deps.model });
160
+ const routerSession = forSubAgent(input.session, AD_HOC_ROUTER_AGENT_ID);
155
161
  const catalog = PLANNABLE_AGENT_CATALOG.map((agent) => `- ${agent.id}: capabilities [${agent.capabilities.join(", ")}]; suitable for [${agent.suitableFor.join(", ")}]`).join("\n");
156
162
  const orientation = await profileOrientation(deps.pool, input.analysisId, logger);
157
163
  const controller = new AbortController();
@@ -160,7 +166,8 @@ export async function routeAdHocRequest(deps, input) {
160
166
  let raw;
161
167
  let failure;
162
168
  try {
163
- const response = unwrapOrThrow(await deps.provider.chat({
169
+ const response = unwrapOrThrow(await deps.provider
170
+ .chat({
164
171
  system: [
165
172
  "Select exactly one specialist for a targeted one-step analysis, estimate its sandbox resources, and name the packages that the step imports.",
166
173
  "Choose only from the supplied catalog. Do not create a plan, reject the request, or select scientific-executor.",
@@ -179,7 +186,19 @@ export async function routeAdHocRequest(deps, input) {
179
186
  // No forced tool choice: some Claude models reject it with a
180
187
  // 400. A reply without the call falls back below as `malformed`.
181
188
  tools: routeTool(),
182
- }, forSubAgent(input.session, AD_HOC_ROUTER_AGENT_ID), signal));
189
+ }, routerSession, signal)
190
+ .map(countChatTokens(AD_HOC_ROUTER_AGENT_ID)));
191
+ // A failed call throws at unwrapOrThrow above, so no usage is recorded.
192
+ accountForChatCall(response, {
193
+ session: routerSession,
194
+ agentId: AD_HOC_ROUTER_AGENT_ID,
195
+ callPath: routerSession.provenance.callPath,
196
+ stepName: AD_HOC_ROUTE_CALL_NAME,
197
+ ...(input.invocationId === undefined ? {} : { invocationId: input.invocationId }),
198
+ usageRecorder: deps.usageRecorder ?? createNoopUsageRecorder(),
199
+ logger,
200
+ rollups: input.turnUsage === undefined ? [] : [input.turnUsage],
201
+ });
183
202
  const call = Array.isArray(response.message.content)
184
203
  ? response.message.content.find((part) => part.type === "tool-call" && part.toolName === "submit_route")
185
204
  : undefined;
@@ -26,7 +26,7 @@ type ShowUserOutput = {
26
26
  id: string;
27
27
  };
28
28
  export declare const showUserTool: import("../define-tool.js").Tool<{
29
- kind: "code" | "markdown" | "table" | "echart" | "svg" | "structure";
29
+ kind: "code" | "markdown" | "echart" | "svg" | "table" | "structure";
30
30
  title?: string | undefined;
31
31
  spec?: Record<string, unknown> | undefined;
32
32
  dataPath?: string | undefined;
@@ -3,6 +3,7 @@
3
3
  * hoc analyses. Both modes launch the ordinary executeAnalysis workflow.
4
4
  */
5
5
  import type { Pool } from "pg";
6
+ import type { UsageRecorder } from "../billing/usage-recorder.js";
6
7
  import type { EnvironmentStorePaths } from "../config/environment-stores.js";
7
8
  import type { ResourcePolicy } from "../config/resource-limits.js";
8
9
  import type { RunAuthorizer } from "../execution/run-authorizer.js";
@@ -29,6 +30,8 @@ export interface ExecuteAnalysisToolDeps extends Pick<EnvironmentStorePaths, "fa
29
30
  * run reserves anything. Without it, the link pass returns at once.
30
31
  */
31
32
  readonly extendAnalysisFarm?: ExtendAnalysisFarm;
33
+ /** Falls back to the no-op recorder when omitted. */
34
+ readonly usageRecorder?: UsageRecorder;
32
35
  }
33
36
  export declare class PlanNotFoundError extends Error {
34
37
  constructor(planId: string);