@inflexa-ai/harness 0.14.1 → 0.15.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 (120) hide show
  1. package/README.md +2 -1
  2. package/dist/agents/conversation-agent.d.ts +7 -0
  3. package/dist/agents/conversation-agent.js +5 -3
  4. package/dist/app/synthesize-run.d.ts +3 -0
  5. package/dist/app/synthesize-run.js +2 -1
  6. package/dist/billing/noop-usage-recorder.d.ts +7 -0
  7. package/dist/billing/noop-usage-recorder.js +10 -0
  8. package/dist/billing/usage-recorder.d.ts +63 -0
  9. package/dist/billing/usage-recorder.js +15 -0
  10. package/dist/contracts/chat-events.d.ts +44 -3
  11. package/dist/contracts/chat-parts.d.ts +38 -2
  12. package/dist/contracts/chat-parts.js +1 -1
  13. package/dist/contracts/data-profile.d.ts +26 -0
  14. package/dist/contracts/data-profile.js +26 -0
  15. package/dist/contracts/index.d.ts +3 -1
  16. package/dist/contracts/index.js +1 -0
  17. package/dist/contracts/message.d.ts +21 -1
  18. package/dist/contracts/part-registry.js +1 -0
  19. package/dist/contracts/schemas/chat-events.d.ts +48 -2
  20. package/dist/contracts/schemas/chat-events.js +8 -1
  21. package/dist/contracts/schemas/chat-parts.d.ts +46 -3
  22. package/dist/contracts/schemas/chat-parts.js +13 -0
  23. package/dist/contracts/schemas/index.d.ts +2 -1
  24. package/dist/contracts/schemas/index.js +2 -1
  25. package/dist/contracts/schemas/usage.d.ts +11 -0
  26. package/dist/contracts/schemas/usage.js +18 -0
  27. package/dist/contracts/usage.d.ts +51 -0
  28. package/dist/contracts/usage.js +41 -0
  29. package/dist/execution/artifact-metadata.d.ts +3 -0
  30. package/dist/execution/artifact-metadata.js +1 -0
  31. package/dist/execution/dbos-workflow-purger.d.ts +30 -0
  32. package/dist/execution/dbos-workflow-purger.js +193 -0
  33. package/dist/execution/post-step-pipeline.d.ts +3 -0
  34. package/dist/execution/post-step-pipeline.js +2 -0
  35. package/dist/execution/reconcile-manifest.js +28 -15
  36. package/dist/execution/report-runner.d.ts +16 -0
  37. package/dist/execution/report-runner.js +4 -0
  38. package/dist/execution/run-synthesis.d.ts +3 -0
  39. package/dist/execution/run-synthesis.js +2 -0
  40. package/dist/execution/step-summary.d.ts +3 -0
  41. package/dist/execution/step-summary.js +1 -0
  42. package/dist/execution/workflow-purger.d.ts +38 -0
  43. package/dist/execution/workflow-purger.js +24 -0
  44. package/dist/index.d.ts +25 -3
  45. package/dist/index.js +28 -1
  46. package/dist/lib/metrics.js +2 -1
  47. package/dist/loop/metrics.d.ts +18 -0
  48. package/dist/loop/metrics.js +18 -1
  49. package/dist/loop/run-agent.d.ts +35 -0
  50. package/dist/loop/run-agent.js +149 -23
  51. package/dist/loop/run-to-terminal.d.ts +5 -2
  52. package/dist/loop/run-to-terminal.js +36 -2
  53. package/dist/loop/tool-detail.d.ts +54 -0
  54. package/dist/loop/tool-detail.js +105 -0
  55. package/dist/loop/tool-outcome.d.ts +21 -0
  56. package/dist/loop/tool-outcome.js +25 -0
  57. package/dist/loop/types.d.ts +15 -1
  58. package/dist/memory/content-to-cortex.d.ts +28 -4
  59. package/dist/memory/content-to-cortex.js +70 -14
  60. package/dist/memory/thread-history.d.ts +21 -1
  61. package/dist/memory/thread-history.js +29 -7
  62. package/dist/memory/thread-store.d.ts +65 -8
  63. package/dist/memory/thread-store.js +81 -17
  64. package/dist/provenance/collector.d.ts +5 -3
  65. package/dist/provenance/collector.js +5 -3
  66. package/dist/providers/ai-sdk.d.ts +18 -0
  67. package/dist/providers/ai-sdk.js +129 -16
  68. package/dist/providers/anthropic.d.ts +2 -0
  69. package/dist/providers/anthropic.js +1 -0
  70. package/dist/providers/streaming-chat.js +5 -0
  71. package/dist/providers/types.d.ts +23 -0
  72. package/dist/providers/types.js +1 -0
  73. package/dist/runtime/assemble.d.ts +25 -8
  74. package/dist/runtime/assemble.js +7 -4
  75. package/dist/sandbox/sandbox-step-translate.d.ts +22 -1
  76. package/dist/sandbox/sandbox-step-translate.js +23 -36
  77. package/dist/schemas/run-synthesis.d.ts +2 -2
  78. package/dist/state/index.d.ts +2 -0
  79. package/dist/state/index.js +3 -0
  80. package/dist/state/init.js +28 -0
  81. package/dist/state/purge-analysis.d.ts +74 -0
  82. package/dist/state/purge-analysis.js +205 -0
  83. package/dist/state/vector-store.d.ts +6 -0
  84. package/dist/state/vector-store.js +22 -2
  85. package/dist/tasks/data-profile-activity.d.ts +14 -2
  86. package/dist/tasks/data-profile-activity.js +1 -1
  87. package/dist/tasks/data-profile.d.ts +3 -0
  88. package/dist/tasks/data-profile.js +9 -3
  89. package/dist/tools/bio/pubmed.js +15 -0
  90. package/dist/tools/bio/search-gene.js +12 -0
  91. package/dist/tools/define-tool.d.ts +41 -3
  92. package/dist/tools/define-tool.js +4 -1
  93. package/dist/tools/detail-resolver.d.ts +35 -0
  94. package/dist/tools/detail-resolver.js +39 -0
  95. package/dist/tools/execute-analysis.js +3 -0
  96. package/dist/tools/iterate-report.d.ts +3 -0
  97. package/dist/tools/iterate-report.js +3 -0
  98. package/dist/tools/memory/update-working-memory.js +8 -0
  99. package/dist/tools/research/generate-analogy-report.d.ts +3 -0
  100. package/dist/tools/research/generate-analogy-report.js +6 -0
  101. package/dist/tools/research/generate-plan.d.ts +3 -0
  102. package/dist/tools/research/generate-plan.js +6 -0
  103. package/dist/tools/research/inspect-run.js +4 -0
  104. package/dist/tools/research/literature-reviewer.d.ts +3 -0
  105. package/dist/tools/research/literature-reviewer.js +6 -0
  106. package/dist/tools/workspace/edit-file.js +2 -0
  107. package/dist/tools/workspace/execute-command.js +25 -0
  108. package/dist/tools/workspace/grep.js +3 -0
  109. package/dist/tools/workspace/list-files.js +2 -0
  110. package/dist/tools/workspace/read-file.js +7 -0
  111. package/dist/tools/workspace/workspace-search.js +1 -0
  112. package/dist/tools/workspace/write-file.js +3 -0
  113. package/dist/workflows/execute-analysis.d.ts +7 -0
  114. package/dist/workflows/execute-analysis.js +31 -2
  115. package/dist/workflows/execute-target-assessment.d.ts +3 -0
  116. package/dist/workflows/sandbox-step.d.ts +23 -0
  117. package/dist/workflows/sandbox-step.js +33 -2
  118. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.d.ts +3 -0
  119. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.js +1 -0
  120. package/package.json +2 -2
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The name-plus-input detail resolver.
3
+ *
4
+ * The live loop holds the resolved `Tool` at dispatch and needs nothing. Every
5
+ * other surface — the reload converter, a workflow's live-activity line — has a
6
+ * tool NAME and a persisted input, so it needs a way back to the tool. This is
7
+ * that way, and it is deliberately built over a tool list the CALLER supplies:
8
+ *
9
+ * - A name map internal to the harness could never see an embedder's tools. A
10
+ * host contributes tools through the host-tools seam, and those calls are as
11
+ * ambiguous in a transcript as any other.
12
+ * - Two harness tools legitimately share one id (`write_file` exists in both the
13
+ * workspace surface and the report version store). A resolver built from one
14
+ * agent's list cannot hold both, because a tool list rejects a duplicate id at
15
+ * registry construction.
16
+ *
17
+ * It resolves through the same `computeDetail` the loop uses, so a reloaded
18
+ * thread cannot show a different detail from the one the live turn showed.
19
+ * Nothing is persisted: storage stays the pure model transcript.
20
+ */
21
+ import { type ToolCallDetail } from "../loop/tool-detail.js";
22
+ import type { Logger } from "../lib/logger.js";
23
+ import type { Tool } from "./define-tool.js";
24
+ /**
25
+ * Maps a tool name plus a call's input to that call's detail. Yields
26
+ * `undefined` for an unknown tool, a tool with no hook, and an input the tool's
27
+ * schema rejects.
28
+ */
29
+ export type ToolDetailResolver = (toolName: string, input: unknown) => ToolCallDetail | undefined;
30
+ /**
31
+ * Build a resolver over `tools`. A duplicate id resolves to the first tool
32
+ * declaring it; callers pass one agent's composed list, which a registry has
33
+ * already rejected duplicates from.
34
+ */
35
+ export declare function createDetailResolver(tools: readonly Tool[], logger?: Logger): ToolDetailResolver;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The name-plus-input detail resolver.
3
+ *
4
+ * The live loop holds the resolved `Tool` at dispatch and needs nothing. Every
5
+ * other surface — the reload converter, a workflow's live-activity line — has a
6
+ * tool NAME and a persisted input, so it needs a way back to the tool. This is
7
+ * that way, and it is deliberately built over a tool list the CALLER supplies:
8
+ *
9
+ * - A name map internal to the harness could never see an embedder's tools. A
10
+ * host contributes tools through the host-tools seam, and those calls are as
11
+ * ambiguous in a transcript as any other.
12
+ * - Two harness tools legitimately share one id (`write_file` exists in both the
13
+ * workspace surface and the report version store). A resolver built from one
14
+ * agent's list cannot hold both, because a tool list rejects a duplicate id at
15
+ * registry construction.
16
+ *
17
+ * It resolves through the same `computeDetail` the loop uses, so a reloaded
18
+ * thread cannot show a different detail from the one the live turn showed.
19
+ * Nothing is persisted: storage stays the pure model transcript.
20
+ */
21
+ import { computeDetail } from "../loop/tool-detail.js";
22
+ import { createNoopLogger } from "../lib/console-logger.js";
23
+ /**
24
+ * Build a resolver over `tools`. A duplicate id resolves to the first tool
25
+ * declaring it; callers pass one agent's composed list, which a registry has
26
+ * already rejected duplicates from.
27
+ */
28
+ export function createDetailResolver(tools, logger) {
29
+ const log = (logger ?? createNoopLogger()).named("tool-detail");
30
+ const byId = new Map();
31
+ for (const tool of tools) {
32
+ if (!byId.has(tool.id))
33
+ byId.set(tool.id, tool);
34
+ }
35
+ return (toolName, input) => {
36
+ const tool = byId.get(toolName);
37
+ return tool === undefined ? undefined : computeDetail(tool, input, log);
38
+ };
39
+ }
@@ -152,6 +152,9 @@ export function createExecuteAnalysisTool(deps) {
152
152
  "that explicit request is consent and needs no synthetic-plan approval. If computation is merely your suggestion, ask first. " +
153
153
  "You never choose the sandbox specialist: ad hoc routing does that automatically. Returns runId with status=in_progress; inspect results on a later turn.",
154
154
  inputSchema,
155
+ // The mode is what a user most needs to see here — an approved plan
156
+ // running is a different event from an ad hoc request being routed.
157
+ describeCall: (input) => (input.mode === "plan" ? `plan ${input.planId ?? ""}` : `ad hoc: ${input.request ?? ""}`),
155
158
  execute: async (input, ctx) => {
156
159
  if (ctx.session.scope.kind !== "analysis")
157
160
  throw new Error("execute_analysis requires an analysis-scoped session");
@@ -48,6 +48,7 @@ import { type StagedAsset } from "./lib/report-preflight.js";
48
48
  import type { PreviewPublisher } from "./report/preview-publisher.js";
49
49
  import type { ChromeConfig } from "../lib/chrome.js";
50
50
  import type { Logger } from "../lib/logger.js";
51
+ import type { UsageRecorder } from "../billing/usage-recorder.js";
51
52
  /**
52
53
  * The report brief the conversation agent composes: title/audience + sources +
53
54
  * the section union. This is the ~12k-char schema that `plan_report` hands back
@@ -209,6 +210,8 @@ export interface SubmitReportDeps {
209
210
  previewId: string;
210
211
  ttlSeconds: number;
211
212
  }) => Promise<PreviewPublisher>;
213
+ /** LLM usage-accounting seam for the report-builder loop; omitted falls back to the no-op recorder. */
214
+ readonly usageRecorder?: UsageRecorder;
212
215
  }
213
216
  /**
214
217
  * `submit_report` — takes the composed brief and drives the report iteration.
@@ -654,6 +654,7 @@ export function createReportSubmitTool(deps) {
654
654
  templatesDir: deps.templatesDir,
655
655
  skillsDir: deps.skillsDir,
656
656
  chrome: deps.chrome,
657
+ usageRecorder: deps.usageRecorder,
657
658
  }, {
658
659
  resourceId,
659
660
  workspaceRoot: analysisRoot,
@@ -665,6 +666,8 @@ export function createReportSubmitTool(deps) {
665
666
  session: ctx.session,
666
667
  signal: ctx.signal,
667
668
  emit: ctx.emit,
669
+ turnUsage: ctx.turnUsage,
670
+ invocationId: ctx.invocationId,
668
671
  });
669
672
  if (!result.ok) {
670
673
  await ctx.emit({
@@ -149,6 +149,14 @@ export function createUpdateWorkingMemoryTool(workingMemory, pool) {
149
149
  "user's last message, run contents (tables, gene lists, file paths), or anything you can retrieve " +
150
150
  "again with inspect_run, read_file, or search.",
151
151
  inputSchema,
152
+ // Four writes in one turn are four identical chips without this. The
153
+ // entry id rides along on revise/retire because it is the only thing
154
+ // separating two retirements from the same section.
155
+ describeCall: (input) => {
156
+ const operation = input.section === "goal" ? "set" : (input.operation ?? "add");
157
+ const target = input.id === undefined ? "" : ` ${input.id}`;
158
+ return `${operation} ${input.section}${target}`;
159
+ },
152
160
  execute: async (input, ctx) => {
153
161
  const analysisId = scopeResource(ctx.session.scope).resourceId;
154
162
  switch (input.section) {
@@ -15,6 +15,7 @@
15
15
  import { z } from "zod";
16
16
  import { type AnalogicalReasonerOutput } from "@inflexa-ai/harness/contracts/analogy-report.js";
17
17
  import type { ChatProvider } from "../../providers/types.js";
18
+ import type { UsageRecorder } from "../../billing/usage-recorder.js";
18
19
  import { type Tool } from "../define-tool.js";
19
20
  import { type BioToolKeys } from "../bio/keys.js";
20
21
  export declare const generateAnalogyReportInputSchema: z.ZodObject<{
@@ -39,6 +40,8 @@ export interface GenerateAnalogyReportDeps {
39
40
  readonly model: string;
40
41
  /** API keys for the bio/chem + GitHub data sources the reasoner searches. */
41
42
  readonly bioKeys: BioToolKeys;
43
+ /** LLM usage-accounting seam for the child loop; omitted falls back to the no-op recorder. */
44
+ readonly usageRecorder?: UsageRecorder;
42
45
  }
43
46
  /** Build the `generate_analogy_report` delegation tool bound to its provider. */
44
47
  export declare function createGenerateAnalogyReportTool(deps: GenerateAnalogyReportDeps): Tool;
@@ -203,6 +203,12 @@ export function createGenerateAnalogyReportTool(deps) {
203
203
  signal: ctx.signal,
204
204
  emit: ctx.emit,
205
205
  runStep: passthroughStep,
206
+ usageRecorder: deps.usageRecorder,
207
+ // Fold the child's calls into the turn total the root loop reports.
208
+ turnUsage: ctx.turnUsage,
209
+ // Keeps the usage record keys of two parallel dispatches disjoint —
210
+ // same frame, same call path, same loop-local step names.
211
+ invocationId: ctx.invocationId,
206
212
  });
207
213
  rawText = finalText(transcript);
208
214
  }
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import type { Pool } from "pg";
27
27
  import { type ChatProvider } from "../../providers/types.js";
28
+ import type { UsageRecorder } from "../../billing/usage-recorder.js";
28
29
  import { type Tool } from "../define-tool.js";
29
30
  import type { EnvironmentStorePaths } from "../../config/environment-stores.js";
30
31
  import { type ResourcePolicy } from "../../config/resource-limits.js";
@@ -49,6 +50,8 @@ export interface GeneratePlanDeps extends EnvironmentStorePaths {
49
50
  readonly resourcePolicy?: ResourcePolicy;
50
51
  /** Diagnostic sink. Absent, the tool runs silently — see `RunAgentOptions.logger`. */
51
52
  readonly logger?: Logger;
53
+ /** LLM usage-accounting seam for the planner loop; omitted falls back to the no-op recorder. */
54
+ readonly usageRecorder?: UsageRecorder;
52
55
  }
53
56
  /** Build the `generate_plan` tool bound to its provider and pool. */
54
57
  export declare function createGeneratePlanTool(deps: GeneratePlanDeps): Tool;
@@ -830,6 +830,12 @@ export function createGeneratePlanTool(deps) {
830
830
  // as soon as one is recorded.
831
831
  toolChoice: "required",
832
832
  logger,
833
+ usageRecorder: deps.usageRecorder,
834
+ // Fold the planner's calls into the turn total the root loop reports.
835
+ turnUsage: ctx.turnUsage,
836
+ // Keeps the usage record keys of two parallel dispatches disjoint —
837
+ // same frame, same call path, same loop-local step names.
838
+ invocationId: ctx.invocationId,
833
839
  }, {
834
840
  tools: innerTools.terminal,
835
841
  nudge: "You ended without a terminal outcome. Call submit_plan with " +
@@ -102,6 +102,10 @@ export function createInspectRunTool(pool, clock = systemClock) {
102
102
  "For a user-directed check, waitForTerminalSeconds can wait once for 1-30 seconds; if the cutoff returns in_progress, stop polling for that turn. " +
103
103
  "Default response is lightweight — pass includeDiagnostics:true to add error/duration/retry telemetry.",
104
104
  inputSchema,
105
+ // The two calls do different work: one inspects a named run, the other
106
+ // pages the list. Naming the run id is what separates repeated polls of
107
+ // the same run from polls of different ones.
108
+ describeCall: (input) => (input.runId === undefined ? `run list (page ${input.page ?? 1})` : input.runId),
105
109
  execute: async (input, ctx) => {
106
110
  const resourceId = scopeResource(ctx.session.scope).resourceId;
107
111
  const verbose = input.includeDiagnostics === true;
@@ -18,6 +18,7 @@ import { type ChatProvider } from "../../providers/types.js";
18
18
  import { type Tool } from "../define-tool.js";
19
19
  import { type BioToolKeys } from "../bio/keys.js";
20
20
  import type { Logger } from "../../lib/logger.js";
21
+ import type { UsageRecorder } from "../../billing/usage-recorder.js";
21
22
  export interface LiteratureReviewerDeps {
22
23
  /** Operational logging seam; omitted falls back to no-op. */
23
24
  readonly logger?: Logger;
@@ -27,6 +28,8 @@ export interface LiteratureReviewerDeps {
27
28
  readonly model: string;
28
29
  /** API keys for the bio/chem data sources the reviewer searches. */
29
30
  readonly bioKeys: BioToolKeys;
31
+ /** LLM usage-accounting seam for the child loop; omitted falls back to the no-op recorder. */
32
+ readonly usageRecorder?: UsageRecorder;
30
33
  }
31
34
  /** Build the `literature_reviewer` delegation tool bound to its provider. */
32
35
  export declare function createLiteratureReviewerTool(deps: LiteratureReviewerDeps): Tool;
@@ -72,6 +72,12 @@ export function createLiteratureReviewerTool(deps) {
72
72
  signal: ctx.signal,
73
73
  emit: ctx.emit,
74
74
  runStep: passthroughStep,
75
+ usageRecorder: deps.usageRecorder,
76
+ // Fold the child's calls into the turn total the root loop reports.
77
+ turnUsage: ctx.turnUsage,
78
+ // Keeps the usage record keys of two parallel reviews disjoint —
79
+ // same frame, same call path, same loop-local step names.
80
+ invocationId: ctx.invocationId,
75
81
  });
76
82
  return ok({ report: finalText(transcript) });
77
83
  },
@@ -65,6 +65,8 @@ export function createEditFileTool(deps) {
65
65
  "/ `out_of_scope` / `file_not_found` / `not_found` / `not_unique` data " +
66
66
  "variants — never throws on expected outcomes.",
67
67
  inputSchema: EditFileInputSchema,
68
+ // The path only. The replaced text is file content, not a description.
69
+ describeCall: ({ path }) => path,
68
70
  execute: async ({ path, old_string, new_string, replace_all }, ctx) => {
69
71
  const read = unwrapOrThrow(await deps.workspaceFilesystem.readFile({
70
72
  session: ctx.session,
@@ -42,6 +42,12 @@ const ExecuteCommandInputSchema = z.object({
42
42
  env: z.record(z.string(), z.string()).optional().describe("Extra environment variables for this command."),
43
43
  timeoutSeconds: z.number().int().positive().optional().describe("Per-command timeout in seconds; capped by the step deadline."),
44
44
  });
45
+ /** Extensions that mark an argv token as the thing being run, not a flag or a data path. */
46
+ const SCRIPT_EXTENSIONS = /\.(py|R|r|sh|js|ts|ipynb)$/;
47
+ /** The first script-like token in an argv, or `undefined` when none looks like one. */
48
+ function scriptToken(command) {
49
+ return command.find((arg) => SCRIPT_EXTENSIONS.test(arg));
50
+ }
45
51
  export function createExecuteCommandTool(deps) {
46
52
  const { sandboxClient, sandbox, workflowId, stepId, nextFunctionId, deadlineMs, defaultCwd, markExecActive, lineageCollector, mountRoot } = deps;
47
53
  return defineTool({
@@ -58,6 +64,25 @@ export function createExecuteCommandTool(deps) {
58
64
  "analysis results via 'python -c' / inline one-liners and read them from " +
59
65
  "stdout; that work is lost. Write a script and persist its outputs to output/.",
60
66
  inputSchema: ExecuteCommandInputSchema,
67
+ // The script is what a reader recognises, and it survives a long
68
+ // invocation whose flags would otherwise push it past the length cap.
69
+ // When nothing in the argv looks like a script, the joined argv is the
70
+ // best account of the call there is.
71
+ //
72
+ // `env` never rides along. It is the channel this tool documents for
73
+ // secrets, so it is the one field guaranteed to hold them. An argv can
74
+ // hold one too (`--password …` is a real habit), and the emit-site
75
+ // redaction only matches the structured, prefixed formats — so the join
76
+ // is a deliberate trade, not a claim of safety.
77
+ //
78
+ // Know what the trade costs. This tool is sandbox-gated, so the only
79
+ // surface that reads its detail is a workflow's live-activity line — and
80
+ // that line goes to the run-event stream through `DBOS.writeStream`. The
81
+ // detail is therefore DURABLE, and every history read replays it. It
82
+ // stays inside the session's own analysis and never reaches the model,
83
+ // but display-only does not mean transient. A secret typed into an argv
84
+ // outlives the turn that ran it.
85
+ describeCall: ({ command }) => scriptToken(command) ?? command.join(" "),
61
86
  execute: async ({ command, cwd, env, timeoutSeconds }, ctx) => {
62
87
  const execId = `${workflowId}:${stepId}:${nextFunctionId()}`;
63
88
  const effectiveCwd = cwd === undefined ? defaultCwd : cwd.startsWith("/") ? cwd : posixPath.join(defaultCwd, cwd);
@@ -38,6 +38,9 @@ export function createGrepTool(fs, workingDir) {
38
38
  "bytes per line — narrow your path or pattern if you hit the cap. " +
39
39
  "No matches and out-of-scope paths return data variants, not errors.",
40
40
  inputSchema: GrepInputSchema,
41
+ // Both fields, because either alone loses the call: the same pattern over
42
+ // two trees and two patterns over one tree are both ordinary sequences.
43
+ describeCall: ({ pattern, path }) => `${pattern} in ${path}`,
41
44
  execute: async ({ pattern, path, ignoreCase }, ctx) => {
42
45
  let regex;
43
46
  try {
@@ -24,6 +24,8 @@ export function createListFilesTool(fs, workingDir) {
24
24
  "else: it never reaches a directory outside the analysis, including the host process's current " +
25
25
  "working directory. Missing and out-of-scope paths return a data variant, not an error.",
26
26
  inputSchema: ListFilesInputSchema,
27
+ // The directory IS the call — two listings differ by nothing else.
28
+ describeCall: ({ path }) => path,
27
29
  execute: async ({ path }, ctx) => {
28
30
  const result = unwrapOrThrow(await fs.list({ session: ctx.session, path, workingDir }));
29
31
  switch (result.kind) {
@@ -62,6 +62,13 @@ export function createReadFileTool(fs, workingDir) {
62
62
  "back as a `binary` data variant rather than decoded garbage. Missing " +
63
63
  "paths and paths outside the analysis tree return a data variant — not an error.",
64
64
  inputSchema: ReadFileInputSchema,
65
+ describeCall: ({ path, headLines, tailLines }) => {
66
+ if (headLines !== undefined)
67
+ return `${path} (first ${headLines} lines)`;
68
+ if (tailLines !== undefined)
69
+ return `${path} (last ${tailLines} lines)`;
70
+ return path;
71
+ },
65
72
  execute: async ({ path, headLines, tailLines }, ctx) => {
66
73
  if (headLines !== undefined && tailLines !== undefined) {
67
74
  return ok({
@@ -47,6 +47,7 @@ export function createWorkspaceSearchTool(pool, embedding) {
47
47
  .describe('Restrict results to one entry type: "input", "output", "summary", or "synthesis"'),
48
48
  limit: z.number().int().min(1).max(50).default(8).describe("Maximum number of results to return"),
49
49
  }),
50
+ describeCall: ({ query, type }) => (type === undefined ? query : `${query} (${type})`),
50
51
  execute: async ({ query, type, limit }, ctx) => {
51
52
  const [vector] = unwrapOrThrow(await embedding.embed([query], ctx.session));
52
53
  if (!vector)
@@ -28,6 +28,9 @@ export function createWriteFileTool(deps) {
28
28
  "variant (no I/O), and one escaping the analysis tree returns " +
29
29
  "`out_of_scope`.",
30
30
  inputSchema: WriteFileInputSchema,
31
+ // The path only. `content` is a whole file and must never ride a display
32
+ // channel, which the emit-site length cap enforces regardless.
33
+ describeCall: ({ path }) => path,
31
34
  execute: async ({ path, content }, ctx) => ok(await deps.mutator.writeFile({ path, content, toolName: "write_file", emit: ctx.emit })),
32
35
  });
33
36
  }
@@ -63,6 +63,7 @@ import type { MachineBudget, ResourceSpec } from "../config/resource-limits.js";
63
63
  import type { RunSession } from "../auth/types.js";
64
64
  import type { RunAuthorizer } from "../execution/run-authorizer.js";
65
65
  import type { Logger } from "../lib/logger.js";
66
+ import type { UsageRecorder } from "../billing/usage-recorder.js";
66
67
  import type { SynthesisStatus, UpdateStepExecutionInput } from "../state/index.js";
67
68
  import type { AnalysisStep } from "../schemas/workflow-state.js";
68
69
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
@@ -280,6 +281,12 @@ export interface ExecuteAnalysisDeps {
280
281
  readonly runCharge: RunCharge;
281
282
  /** Run-authorization seam — the terminal path revokes through `revoke`. */
282
283
  readonly runAuthorizer: RunAuthorizer;
284
+ /**
285
+ * LLM usage-accounting seam for the in-body synthesizer loop (and the
286
+ * literature reviewer it delegates to); omitted falls back to the no-op
287
+ * recorder.
288
+ */
289
+ readonly usageRecorder?: UsageRecorder;
283
290
  /**
284
291
  * Optional, fire-and-forget provenance observation of the run lifecycle.
285
292
  * Deliberately invoked directly in the workflow body (NOT wrapped in
@@ -66,6 +66,7 @@ import { createNoopLogger } from "../lib/console-logger.js";
66
66
  import { unwrapOrThrow } from "../lib/result.js";
67
67
  import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
68
68
  import { isBudgetExceeded } from "../loop/budget-exceeded.js";
69
+ import { addChatUsage, hasReportedUsage } from "../loop/metrics.js";
69
70
  import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
70
71
  import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing } from "../prompts/briefing.js";
71
72
  import { SYNTHESIS_STEP_ID, runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
@@ -319,11 +320,22 @@ export function synthesizeFindings(args) {
319
320
  resolveWorkspaceRoot: deps.resolveWorkspaceRoot,
320
321
  synthesisModel: deps.synthesisModel,
321
322
  bioKeys: deps.bioKeys,
323
+ usageRecorder: deps.usageRecorder,
322
324
  }, {
323
325
  analysisId,
324
326
  runId,
325
327
  completedSteps,
326
- session,
328
+ // Stepped, exactly as a sandbox step's child input is. Synthesis owns a real row in the
329
+ // step-execution ledger (seeded and updated under `SYNTHESIS_STEP_ID` in the body below),
330
+ // so it lists as a step everywhere a run's steps are shown. Handing it the bare run session
331
+ // left every call it makes — the synthesizer loop and each sub-agent it dispatches —
332
+ // recorded against the run carrying no `stepId`, which made the one step in the list unable
333
+ // to report a figure the one that had just spent the most.
334
+ //
335
+ // It also puts the step id into the replay-stable `recordKey`, which is what it is for:
336
+ // synthesis and a plan step of the same run can no longer mint the same key from the same
337
+ // call path.
338
+ session: forStep(session, SYNTHESIS_STEP_ID),
327
339
  emit,
328
340
  onProgress: (phase, activity, extra = {}) => DBOS.writeStream("events", {
329
341
  type: "data-synthesis-progress",
@@ -530,6 +542,7 @@ export async function runExecuteAnalysisBody(input, deps) {
530
542
  failed: final.failed,
531
543
  canceled: final.canceled,
532
544
  budgetExceeded: final.budgetExceeded,
545
+ ...(final.usage ? { usage: final.usage } : {}),
533
546
  failureReason: synthesisError
534
547
  ? synthesisError instanceof Error
535
548
  ? `synthesis-failed: ${synthesisError.message}`
@@ -601,6 +614,12 @@ async function runSchedulerLoop(args) {
601
614
  const canceled = new Set();
602
615
  const inFlight = new Map();
603
616
  const budgetExceededChildIds = new Set();
617
+ // Run-level usage aggregate, folded from the children's own durable results.
618
+ // `getResult` IS the replay-safe source: a recovered parent reads back the
619
+ // identical value from each child's workflow record, so the sum reproduces
620
+ // exactly without a second channel. A child that settled by throwing carries
621
+ // no result and therefore contributes nothing — same rule as its duration.
622
+ const runUsage = {};
604
623
  // Child workflow ids the parent itself cancelled (budget / neverFits halt cascade). Their `getResult`
605
624
  // rejects with a DBOS cancellation error and lands in the settlement error branch — but the step was
606
625
  // CANCELED by us, not failed on its own merits. Keyed here (deterministic: `cancelInFlight` runs the
@@ -798,6 +817,7 @@ async function runSchedulerLoop(args) {
798
817
  let stepDurationMs;
799
818
  if (settled.kind === "result") {
800
819
  const r = settled.result;
820
+ addChatUsage(runUsage, r.usage);
801
821
  if (r.status === "complete") {
802
822
  completed.add(settled.stepId);
803
823
  stepRuntime.set(settled.stepId, {
@@ -912,7 +932,15 @@ async function runSchedulerLoop(args) {
912
932
  });
913
933
  }
914
934
  await drainBudgetExceededNotifications(budgetExceededChildIds);
915
- return { completed, failed, canceled, budgetExceeded, failureReason, stepStates: stepRuntime };
935
+ return {
936
+ completed,
937
+ failed,
938
+ canceled,
939
+ budgetExceeded,
940
+ failureReason,
941
+ stepStates: stepRuntime,
942
+ ...(hasReportedUsage(runUsage) ? { usage: { ...runUsage } } : {}),
943
+ };
916
944
  }
917
945
  async function drainBudgetExceededNotifications(childIds) {
918
946
  while (true) {
@@ -1068,6 +1096,7 @@ async function collectAndComplete(args) {
1068
1096
  note: `${completed.size} of ${input.steps.length} steps completed; results are partial.`,
1069
1097
  }
1070
1098
  : {}),
1099
+ ...(args.usage ? { usage: args.usage } : {}),
1071
1100
  });
1072
1101
  }
1073
1102
  else {
@@ -35,6 +35,7 @@ import { z } from "zod";
35
35
  import type { RunSession } from "../auth/types.js";
36
36
  import type { RunAuthorizer } from "../execution/run-authorizer.js";
37
37
  import type { Logger } from "../lib/logger.js";
38
+ import type { UsageRecorder } from "../billing/usage-recorder.js";
38
39
  import type { AgentChat } from "../providers/types.js";
39
40
  import { TargetAssessmentInputSchema } from "./target-assessment/schemas.js";
40
41
  import { type ProgressPhase } from "./target-assessment/progress.js";
@@ -88,6 +89,8 @@ export interface ExecuteTargetAssessmentDeps {
88
89
  readonly decisionModel: string;
89
90
  /** Billing-gateway model for synthesis agents — typically `env.TARGET_ASSESSMENT_SYNTHESIS_MODEL`. */
90
91
  readonly synthesisModel: string;
92
+ /** LLM usage-accounting seam for the assessment's agent loops; omitted falls back to the no-op recorder. */
93
+ readonly usageRecorder?: UsageRecorder;
91
94
  }
92
95
  /**
93
96
  * Register the executeTargetAssessment workflow with DBOS. Caller is
@@ -26,8 +26,10 @@
26
26
  * parent (`executeAnalysis`). This child only owns its own teardown and
27
27
  * its own self-cancel on 402.
28
28
  */
29
+ import type { TokenUsageRollup } from "@inflexa-ai/harness/contracts/usage.js";
29
30
  import type { Pool } from "pg";
30
31
  import type { Logger } from "../lib/logger.js";
32
+ import type { UsageRecorder } from "../billing/usage-recorder.js";
31
33
  import type { AgentDefinition, LoopMessage } from "../loop/types.js";
32
34
  import type { AgentChat, EmbeddingProvider } from "../providers/types.js";
33
35
  import { type RunSession } from "../auth/types.js";
@@ -125,6 +127,17 @@ export interface SandboxStepResult {
125
127
  readonly durationMs: number;
126
128
  readonly finishReason: string | null;
127
129
  readonly error: string | null;
130
+ /**
131
+ * What this step's agent loop reported spending. Absent when it reported
132
+ * nothing — never an all-zero figure.
133
+ *
134
+ * Durable by virtue of being the child's workflow result: the parent folds
135
+ * the run's aggregate from these, and a recovered parent reads back the same
136
+ * values, so the aggregate needs no side channel to survive replay. Optional
137
+ * because this is a durable shape — a child that settled under an older
138
+ * result shape recovers without it.
139
+ */
140
+ readonly usage?: TokenUsageRollup;
128
141
  }
129
142
  /** One file in the step's terminal file tree / output listing. */
130
143
  export interface StepFileEntry {
@@ -204,6 +217,11 @@ export interface SandboxStepDeps {
204
217
  * account of why a step died.
205
218
  */
206
219
  readonly logger?: Logger;
220
+ /**
221
+ * LLM usage-accounting seam. Covers this step's agent loop and the
222
+ * post-step sub-agent loops alike; omitted falls back to the no-op recorder.
223
+ */
224
+ readonly usageRecorder?: UsageRecorder;
207
225
  /** Non-streaming chat — drives the agent loop + the post-step sub-agents. */
208
226
  readonly provider: AgentChat;
209
227
  /** Write-side embedder for the post-step vector index. */
@@ -275,3 +293,8 @@ export declare function createLineageCollector(input: Pick<SandboxStepInput, "st
275
293
  * `dbos.application_versions` without per-workflow config.
276
294
  */
277
295
  export declare function registerSandboxStep(deps: SandboxStepDeps): (input: SandboxStepInput) => Promise<SandboxStepResult>;
296
+ /**
297
+ * Body extracted so tests can drive it without registering a workflow
298
+ * (the DBOS calls inside still rely on a workflow context being present).
299
+ */
300
+ export declare function runSandboxStepBody(input: SandboxStepInput, deps: SandboxStepDeps): Promise<SandboxStepResult>;
@@ -34,6 +34,7 @@ import { isBudgetExceeded } from "../loop/budget-exceeded.js";
34
34
  import { runAgent } from "../loop/run-agent.js";
35
35
  import { durableStep } from "../loop/run-step.js";
36
36
  import { activityForTool, applyTreeDelta, isChatDataPart, sandboxTreeDelta, stepPartId } from "../sandbox/sandbox-step-translate.js";
37
+ import { createDetailResolver } from "../tools/detail-resolver.js";
37
38
  import { forSubAgent } from "../auth/types.js";
38
39
  import { walkStepArtifacts } from "../execution/post-step.js";
39
40
  import { collectStepOutputs, generateStepFileMetadata, generateStepSummaryAndWrite, reconcileAndRegisterStepArtifacts, vectorIndexStepOutputs, } from "../execution/post-step-pipeline.js";
@@ -110,7 +111,7 @@ export function registerSandboxStep(deps) {
110
111
  * Body extracted so tests can drive it without registering a workflow
111
112
  * (the DBOS calls inside still rely on a workflow context being present).
112
113
  */
113
- async function runSandboxStepBody(input, deps) {
114
+ export async function runSandboxStepBody(input, deps) {
114
115
  // Step coordinates bind once — every record below carries them without each
115
116
  // call site restating them. REPLAY: a recovered workflow re-executes this body
116
117
  // and re-emits its records, so a reader dedups by (runId, stepId), not by count.
@@ -193,6 +194,9 @@ async function runSandboxStepBody(input, deps) {
193
194
  lineageCollector,
194
195
  blockerHolder,
195
196
  });
197
+ // Built over THIS agent's tools, not a shared table: a sandbox agent's roster
198
+ // differs per agent type, and each tool describes its own calls.
199
+ const resolveDetail = createDetailResolver(agent.tools, logger);
196
200
  // Forward every loop event onto the child's own DBOS `"events"` stream
197
201
  // (the route fans-in parent + every active child stream — Pattern A).
198
202
  // Pre-shaped chat data parts (e.g. `data-sandbox-event` from `run-exec.ts`)
@@ -301,7 +305,7 @@ async function runSandboxStepBody(input, deps) {
301
305
  // envelope, and surface each tool call as live step activity.
302
306
  await safeEmit({ type: "data-loop-event", data: { stepId: input.stepId, event } });
303
307
  if (event.type === "tool-started") {
304
- await emitActivity("executing", activityForTool(event.name, event.input));
308
+ await emitActivity("executing", activityForTool(event.name, event.input, resolveDetail));
305
309
  }
306
310
  };
307
311
  const initial = [{ role: "user", content: input.prompt }];
@@ -312,12 +316,14 @@ async function runSandboxStepBody(input, deps) {
312
316
  let transcript;
313
317
  let finishReason;
314
318
  let hitMaxSteps;
319
+ let stepUsage;
315
320
  try {
316
321
  const agentResult = await runAgent(agent, initial, session, {
317
322
  provider: deps.provider,
318
323
  signal: neverAbort,
319
324
  emit,
320
325
  runStep: durableStep,
326
+ usageRecorder: deps.usageRecorder,
321
327
  formatStepName: {
322
328
  llm: (iteration) => `llm:${iteration}`,
323
329
  tool: (toolName, toolUseId) => `tool:${toolName}:${toolUseId}`,
@@ -327,6 +333,7 @@ async function runSandboxStepBody(input, deps) {
327
333
  transcript = agentResult.messages;
328
334
  finishReason = agentResult.finish.reason;
329
335
  hitMaxSteps = agentResult.finish.cappedOut;
336
+ stepUsage = agentResult.finish.usage;
330
337
  }
331
338
  catch (err) {
332
339
  if (isBudgetExceeded(err)) {
@@ -380,6 +387,28 @@ async function runSandboxStepBody(input, deps) {
380
387
  // `failStep` always throws; the outer `throw` is for control-flow analysis.
381
388
  throw await failStep("agent_loop", err);
382
389
  }
390
+ // What the step's loop spent, emitted the moment the loop is done and before
391
+ // any branch that can end the step — so a step that later blocks or fails its
392
+ // post-step pipeline has still reported what it cost.
393
+ //
394
+ // No rollup means NO PART: the part's contract is to carry a step's rollup,
395
+ // and a loop that reported nothing has none to carry. A part naming the step
396
+ // with an absent usage field would be a claim about a figure nobody made.
397
+ //
398
+ // Stable id per (runId, stepId), like every other step part here, so a
399
+ // DBOS-replayed body's re-emission folds latest-wins onto the one part
400
+ // instead of appearing twice on the run's stream.
401
+ if (stepUsage) {
402
+ await safeEmit({
403
+ type: "data-step-usage",
404
+ id: stepPartId("step-usage", input.runId, input.stepId),
405
+ runId: input.runId,
406
+ stepId: input.stepId,
407
+ agentId: input.agentId,
408
+ modelId: agent.model,
409
+ usage: stepUsage,
410
+ });
411
+ }
383
412
  // Blocker (see the harness-sandbox-agents spec): the agent declared it cannot fulfil the step. A
384
413
  // blocked step has no deliverables — skip the artifact post-step pipeline,
385
414
  // mark `blocked` (carrying the reason), emit the blocked part, and return a
@@ -414,6 +443,7 @@ async function runSandboxStepBody(input, deps) {
414
443
  durationMs,
415
444
  finishReason,
416
445
  error: blockerOutcome.reason,
446
+ ...(stepUsage ? { usage: stepUsage } : {}),
417
447
  };
418
448
  }
419
449
  // (4-8) post-step — the body is the explicit composition: walk the artifact
@@ -547,6 +577,7 @@ async function runSandboxStepBody(input, deps) {
547
577
  durationMs,
548
578
  finishReason,
549
579
  error: null,
580
+ ...(stepUsage ? { usage: stepUsage } : {}),
550
581
  };
551
582
  }
552
583
  /**