@inflexa-ai/harness 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) 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/agents/sandbox/drug-repurposing-agent.js +5 -1
  5. package/dist/agents/sandbox/immune-profiling-agent.js +4 -1
  6. package/dist/app/synthesize-run.d.ts +3 -0
  7. package/dist/app/synthesize-run.js +2 -1
  8. package/dist/billing/noop-usage-recorder.d.ts +7 -0
  9. package/dist/billing/noop-usage-recorder.js +10 -0
  10. package/dist/billing/usage-recorder.d.ts +63 -0
  11. package/dist/billing/usage-recorder.js +15 -0
  12. package/dist/contracts/chat-events.d.ts +44 -3
  13. package/dist/contracts/chat-parts.d.ts +38 -2
  14. package/dist/contracts/chat-parts.js +1 -1
  15. package/dist/contracts/data-profile.d.ts +26 -0
  16. package/dist/contracts/data-profile.js +26 -0
  17. package/dist/contracts/index.d.ts +3 -1
  18. package/dist/contracts/index.js +1 -0
  19. package/dist/contracts/message.d.ts +21 -1
  20. package/dist/contracts/part-registry.js +1 -0
  21. package/dist/contracts/schemas/chat-events.d.ts +48 -2
  22. package/dist/contracts/schemas/chat-events.js +8 -1
  23. package/dist/contracts/schemas/chat-parts.d.ts +46 -3
  24. package/dist/contracts/schemas/chat-parts.js +13 -0
  25. package/dist/contracts/schemas/index.d.ts +2 -1
  26. package/dist/contracts/schemas/index.js +2 -1
  27. package/dist/contracts/schemas/usage.d.ts +11 -0
  28. package/dist/contracts/schemas/usage.js +18 -0
  29. package/dist/contracts/usage.d.ts +51 -0
  30. package/dist/contracts/usage.js +41 -0
  31. package/dist/execution/artifact-metadata.d.ts +3 -0
  32. package/dist/execution/artifact-metadata.js +1 -0
  33. package/dist/execution/dbos-workflow-purger.d.ts +30 -0
  34. package/dist/execution/dbos-workflow-purger.js +193 -0
  35. package/dist/execution/post-step-pipeline.d.ts +3 -0
  36. package/dist/execution/post-step-pipeline.js +2 -0
  37. package/dist/execution/reconcile-manifest.js +28 -15
  38. package/dist/execution/report-runner.d.ts +16 -0
  39. package/dist/execution/report-runner.js +4 -0
  40. package/dist/execution/run-synthesis.d.ts +3 -0
  41. package/dist/execution/run-synthesis.js +2 -0
  42. package/dist/execution/step-summary.d.ts +3 -0
  43. package/dist/execution/step-summary.js +1 -0
  44. package/dist/execution/workflow-purger.d.ts +38 -0
  45. package/dist/execution/workflow-purger.js +24 -0
  46. package/dist/index.d.ts +24 -2
  47. package/dist/index.js +27 -0
  48. package/dist/lib/metrics.js +2 -1
  49. package/dist/loop/metrics.d.ts +18 -0
  50. package/dist/loop/metrics.js +18 -1
  51. package/dist/loop/run-agent.d.ts +35 -0
  52. package/dist/loop/run-agent.js +149 -23
  53. package/dist/loop/run-to-terminal.d.ts +5 -2
  54. package/dist/loop/run-to-terminal.js +36 -2
  55. package/dist/loop/tool-detail.d.ts +54 -0
  56. package/dist/loop/tool-detail.js +105 -0
  57. package/dist/loop/tool-outcome.d.ts +21 -0
  58. package/dist/loop/tool-outcome.js +25 -0
  59. package/dist/loop/types.d.ts +15 -1
  60. package/dist/memory/content-to-cortex.d.ts +28 -4
  61. package/dist/memory/content-to-cortex.js +70 -14
  62. package/dist/memory/thread-history.d.ts +21 -1
  63. package/dist/memory/thread-history.js +29 -7
  64. package/dist/memory/thread-store.d.ts +65 -8
  65. package/dist/memory/thread-store.js +81 -17
  66. package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
  67. package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +17 -13
  68. package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
  69. package/dist/prompts/sandbox/chromatin-agent.js +45 -54
  70. package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
  71. package/dist/prompts/sandbox/genomic-variant-agent.js +39 -37
  72. package/dist/prompts/sandbox-standards.d.ts +1 -1
  73. package/dist/prompts/sandbox-standards.js +21 -2
  74. package/dist/provenance/collector.d.ts +5 -3
  75. package/dist/provenance/collector.js +5 -3
  76. package/dist/providers/ai-sdk.js +91 -16
  77. package/dist/providers/streaming-chat.js +5 -0
  78. package/dist/providers/types.d.ts +23 -0
  79. package/dist/providers/types.js +1 -0
  80. package/dist/reference-data/catalog.js +536 -0
  81. package/dist/runtime/assemble.d.ts +25 -8
  82. package/dist/runtime/assemble.js +7 -4
  83. package/dist/sandbox/sandbox-step-translate.d.ts +22 -1
  84. package/dist/sandbox/sandbox-step-translate.js +23 -36
  85. package/dist/schemas/run-synthesis.d.ts +2 -2
  86. package/dist/state/index.d.ts +2 -0
  87. package/dist/state/index.js +3 -0
  88. package/dist/state/init.js +28 -0
  89. package/dist/state/purge-analysis.d.ts +74 -0
  90. package/dist/state/purge-analysis.js +205 -0
  91. package/dist/state/vector-store.d.ts +6 -0
  92. package/dist/state/vector-store.js +22 -2
  93. package/dist/tasks/data-profile-activity.d.ts +14 -2
  94. package/dist/tasks/data-profile-activity.js +1 -1
  95. package/dist/tasks/data-profile.d.ts +3 -0
  96. package/dist/tasks/data-profile.js +9 -3
  97. package/dist/tools/bio/pubmed.js +15 -0
  98. package/dist/tools/bio/search-gene.js +12 -0
  99. package/dist/tools/define-tool.d.ts +41 -3
  100. package/dist/tools/define-tool.js +4 -1
  101. package/dist/tools/detail-resolver.d.ts +35 -0
  102. package/dist/tools/detail-resolver.js +39 -0
  103. package/dist/tools/execute-analysis.js +3 -0
  104. package/dist/tools/iterate-report.d.ts +3 -0
  105. package/dist/tools/iterate-report.js +3 -0
  106. package/dist/tools/memory/update-working-memory.js +8 -0
  107. package/dist/tools/research/generate-analogy-report.d.ts +3 -0
  108. package/dist/tools/research/generate-analogy-report.js +6 -0
  109. package/dist/tools/research/generate-plan.d.ts +3 -0
  110. package/dist/tools/research/generate-plan.js +6 -0
  111. package/dist/tools/research/inspect-run.js +4 -0
  112. package/dist/tools/research/literature-reviewer.d.ts +3 -0
  113. package/dist/tools/research/literature-reviewer.js +6 -0
  114. package/dist/tools/workspace/edit-file.js +2 -0
  115. package/dist/tools/workspace/execute-command.js +25 -0
  116. package/dist/tools/workspace/grep.js +3 -0
  117. package/dist/tools/workspace/list-files.js +2 -0
  118. package/dist/tools/workspace/read-file.js +7 -0
  119. package/dist/tools/workspace/workspace-search.js +1 -0
  120. package/dist/tools/workspace/write-file.js +3 -0
  121. package/dist/workflows/execute-analysis.d.ts +7 -0
  122. package/dist/workflows/execute-analysis.js +31 -2
  123. package/dist/workflows/execute-target-assessment.d.ts +3 -0
  124. package/dist/workflows/sandbox-step.d.ts +23 -0
  125. package/dist/workflows/sandbox-step.js +33 -2
  126. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.d.ts +3 -0
  127. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.js +1 -0
  128. package/package.json +1 -1
@@ -20,6 +20,7 @@
20
20
  * no terminal at all.
21
21
  */
22
22
  import type { Logger } from "../lib/logger.js";
23
+ import type { ToolDetailResolver } from "../tools/detail-resolver.js";
23
24
  /**
24
25
  * Writes one part to the run-event stream. Injected rather than imported so this
25
26
  * module stays free of the durability engine and so a test can record what a
@@ -51,8 +52,19 @@ export interface ProfileActivityEmitter {
51
52
  sandboxInit(): Promise<void>;
52
53
  /** Reported once the sandbox is ready, covering the gap before the agent's first tool call. */
53
54
  agentStarting(): Promise<void>;
54
- /** Reported per tool call the profiler agent makes, phrased by the shared translator. */
55
- forTool(name: string, input: unknown): Promise<void>;
55
+ /**
56
+ * Reported per tool call the profiler agent makes, phrased by the shared
57
+ * translator.
58
+ *
59
+ * `resolveDetail` is built over the profiler agent's own tool list, so the
60
+ * phrase comes from the called tool's `describeCall` hook. It is passed per
61
+ * call rather than held by the emitter because the emitter exists before the
62
+ * agent does — `sandboxInit` is reported before the sandbox the agent's tools
63
+ * are bound to. The call site supplies a RESOLVER, never a phrase: the
64
+ * phrases are this capability's observable contract, and a call site that
65
+ * composed its own is a call site that can drift from it.
66
+ */
67
+ forTool(name: string, input: unknown, resolveDetail?: ToolDetailResolver): Promise<void>;
56
68
  /** Reported for the vector-store pass that indexes per-file descriptions. */
57
69
  indexing(): Promise<void>;
58
70
  /** The success terminal. Emitted only after the terminal ledger write has landed. */
@@ -48,7 +48,7 @@ export function createProfileActivityEmitter(write, frame, logger) {
48
48
  return {
49
49
  sandboxInit: () => emit("sandbox-init", "Starting sandbox"),
50
50
  agentStarting: () => emit("executing", "Running data-profiler"),
51
- forTool: (name, input) => emit("executing", activityForTool(name, input)),
51
+ forTool: (name, input, resolveDetail) => emit("executing", activityForTool(name, input, resolveDetail)),
52
52
  indexing: () => emit("indexing", "Indexing input descriptions for search"),
53
53
  complete: () => emit("complete", "Profile complete"),
54
54
  failed: (reason) => emit("failed", reason),
@@ -23,6 +23,7 @@ import type { RunAuthorization, RunAuthorizer } from "../execution/run-authorize
23
23
  import type { StagedInput } from "../execution/staged-input.js";
24
24
  import type { BioToolKeys } from "../tools/bio/keys.js";
25
25
  import type { Logger } from "../lib/logger.js";
26
+ import type { UsageRecorder } from "../billing/usage-recorder.js";
26
27
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
27
28
  import type { SandboxClient } from "../sandbox/client.js";
28
29
  import type { WorkspaceFilesystem } from "../workspace/filesystem.js";
@@ -53,6 +54,8 @@ export interface DataProfileDeps extends EnvironmentStorePaths {
53
54
  readonly embedding: EmbeddingProvider;
54
55
  /** Absolute path to the skills tree (one subdirectory per skill). */
55
56
  readonly skillsDir: string;
57
+ /** LLM usage-accounting seam for the profiler agent loop; omitted falls back to the no-op recorder. */
58
+ readonly usageRecorder?: UsageRecorder;
56
59
  }
57
60
  /**
58
61
  * Workflow input. JSON-serialisable — DBOS persists it as the workflow's
@@ -26,6 +26,7 @@ import { durableStep } from "../loop/run-step.js";
26
26
  import { createNoopLogger } from "../lib/console-logger.js";
27
27
  import { unwrapOrThrow } from "../lib/result.js";
28
28
  import { defineTool } from "../tools/define-tool.js";
29
+ import { createDetailResolver } from "../tools/detail-resolver.js";
29
30
  import { renderWorkspace } from "../prompts/briefing.js";
30
31
  import { toSandboxPath } from "../workspace/paths.js";
31
32
  import { estimateDataProfileResources } from "../sandbox/estimate-data-profile-resources.js";
@@ -36,8 +37,9 @@ import { ProfilerOutputSchema } from "../schemas/data-profile-schemas.js";
36
37
  import { completeDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
37
38
  import { createProfileActivityEmitter } from "./data-profile-activity.js";
38
39
  import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.js";
39
- /** Synthetic run/step literal for the data-profile workflow. */
40
- const DATA_PROFILE_RUN_LITERAL = "data-profile";
40
+ // The run literal is declared in `contracts/` — a consumer reads it back off recorded usage, and this
41
+ // module is unimportable to one that only wants the string (see the constant's own doc).
42
+ import { DATA_PROFILE_RUN_LITERAL } from "../contracts/data-profile.js";
41
43
  const DATA_PROFILE_STEP_LITERAL = "profile";
42
44
  const DATA_PROFILE_AGENT_ID = "data-profiler";
43
45
  /** Sandbox-server exec budget for the profile run. */
@@ -277,6 +279,9 @@ export async function runDataProfileBody(input, deps) {
277
279
  });
278
280
  const baseAgent = createDataProfilerAgent(sandboxAgentDeps);
279
281
  const agentDef = { ...baseAgent, tools: [...baseAgent.tools, submitProfileTool] };
282
+ // The profiler's own roster, `submit_profile` included, so every call it
283
+ // makes is phrased by the tool that was called.
284
+ const resolveDetail = createDetailResolver(agentDef.tools, logger);
280
285
  const signal = new AbortController().signal;
281
286
  // Covers the gap between a ready sandbox and the agent's first tool call, which would
282
287
  // otherwise still read as `Starting sandbox` long after the container was up. The run
@@ -294,10 +299,11 @@ export async function runDataProfileBody(input, deps) {
294
299
  // counter and desynchronise the recorded sequence on replay.
295
300
  emit: async (event) => {
296
301
  if (event.type === "tool-started")
297
- await activity.forTool(event.name, event.input);
302
+ await activity.forTool(event.name, event.input, resolveDetail);
298
303
  },
299
304
  runStep: durableStep,
300
305
  resolved: () => capturedProfile !== null,
306
+ usageRecorder: deps.usageRecorder,
301
307
  }, {
302
308
  tools: [submitProfileTool],
303
309
  nudge: "You stopped without calling submit_profile, so no profile was " +
@@ -125,6 +125,21 @@ export function createPubMedTool(deps) {
125
125
  "`outline` from the first call and re-request the specific `sections` you need rather than raising maxChars. " +
126
126
  "available: false is an expected outcome, not an error — do not retry it.",
127
127
  inputSchema,
128
+ // One tool, three jobs. The action alone would still render three
129
+ // different searches identically, so each action names its own subject:
130
+ // the query, how many articles, or which article.
131
+ describeCall: (input) => {
132
+ switch (input.action) {
133
+ case "search":
134
+ return `search ${input.query ?? ""}`;
135
+ case "details": {
136
+ const count = input.pmids?.length ?? 0;
137
+ return `details for ${count} ${count === 1 ? "article" : "articles"}`;
138
+ }
139
+ case "fulltext":
140
+ return `fulltext ${input.pmcId ?? ""}`;
141
+ }
142
+ },
128
143
  execute: async (input) => {
129
144
  switch (input.action) {
130
145
  case "search":
@@ -5,6 +5,12 @@ import { ok } from "neverthrow";
5
5
  import { z } from "zod";
6
6
  import { defineTool } from "../define-tool.js";
7
7
  import { lookupGenes } from "../lib/ensembl-client.js";
8
+ /**
9
+ * How many symbols a detail names before it switches to a count plus a sample.
10
+ * Eight symbols of typical length stay well inside the emit-site cap, so the
11
+ * listed form is never the thing that trips it.
12
+ */
13
+ const SYMBOLS_LISTED_IN_FULL = 8;
8
14
  export const searchGeneTool = defineTool({
9
15
  id: "search_gene",
10
16
  description: "Resolve gene symbols against Ensembl — the identifier step other tools depend on. Per symbol: Ensembl gene ID (ENSG…), coordinates, strand, " +
@@ -14,5 +20,11 @@ export const searchGeneTool = defineTool({
14
20
  symbols: z.array(z.string()).min(1).max(200).describe("Gene symbols to look up (e.g. ['BRCA1', 'TP53'])"),
15
21
  species: z.string().default("homo_sapiens").describe("Species name (e.g. 'homo_sapiens', 'mus_musculus')"),
16
22
  }),
23
+ // The symbols are the call. A large batch is summarized here rather than
24
+ // left to the emit-site cap: that cap cuts at a character, so a 200-symbol
25
+ // join arrives ending in `TP5` — a fragment that reads as a real symbol and
26
+ // says nothing about how many were dropped. The count leads, so a batch is
27
+ // legible as a batch even when the sample after it is itself trimmed.
28
+ describeCall: ({ symbols }) => symbols.length <= SYMBOLS_LISTED_IN_FULL ? symbols.join(", ") : `${symbols.length} genes: ${symbols.slice(0, SYMBOLS_LISTED_IN_FULL).join(", ")}, …`,
17
29
  execute: async ({ symbols, species }) => ok(await lookupGenes(symbols, { species })),
18
30
  });
@@ -8,7 +8,7 @@
8
8
  * captures its deps and calls `defineTool`.
9
9
  *
10
10
  * `ToolContext` carries only request-scoped values every tool may need
11
- * (`invocationId`, `session`, `signal`, `emit`, `runStep`, `ask`) — no pool, no sandbox, no
11
+ * (`invocationId`, `session`, `signal`, `emit`, `runStep`, `ask`, `turnUsage`) — no pool, no sandbox, no
12
12
  * logger. The error contract: an expected
13
13
  * outcome ("not found", "no results") stays in the ok channel as a data
14
14
  * variant (`ok({ found: false })`); an unexpected failure is an `err(ToolError)`
@@ -18,6 +18,7 @@
18
18
  import type { Result } from "neverthrow";
19
19
  import { z } from "zod";
20
20
  import type { AgentSession } from "../auth/types.js";
21
+ import type { AgentRunUsage } from "../loop/metrics.js";
21
22
  import type { EmitFn, RunStep } from "../loop/types.js";
22
23
  import type { AskApproval, AskRequest } from "./approval/contract.js";
23
24
  export type { EmitFn };
@@ -42,7 +43,8 @@ export declare function isToolError(value: unknown): value is ToolError;
42
43
  * dependencies (see the harness-durable-runtime spec) — invocation identity,
43
44
  * `session`, `signal`, `emit`, the `runStep`
44
45
  * durability seam (`passthroughStep` in chat, `DBOS.runStep` in workflows) a
45
- * tool uses to wrap its own durable work, and the `ask` user-approval seam.
46
+ * tool uses to wrap its own durable work, the `ask` user-approval seam, and the
47
+ * turn's usage accumulator a sub-agent-running tool hands to its child loop.
46
48
  */
47
49
  export interface ToolContext {
48
50
  /** Stable identity of this AI SDK tool call. Redelivery preserves it; a new
@@ -64,10 +66,18 @@ export interface ToolContext {
64
66
  * waiting on a surface that cannot answer.
65
67
  */
66
68
  readonly ask: (request: AskRequest) => Promise<AskApproval>;
69
+ /**
70
+ * The turn's usage accumulator — the mutable total every loop under this
71
+ * turn folds its LLM calls into. A tool that drives a child `runAgent`
72
+ * passes it straight into that run's options, which is what puts the
73
+ * child's tokens in the turn total its root loop reports. A tool that runs
74
+ * no child agent ignores it.
75
+ */
76
+ readonly turnUsage?: AgentRunUsage;
67
77
  }
68
78
  /**
69
79
  * A packaged tool: identity, the Zod input contract, the emitted AI SDK input
70
- * schema, execution mode, and the executor.
80
+ * schema, execution mode, the optional call-description hook, and the executor.
71
81
  */
72
82
  export interface Tool<Input = unknown, Output = unknown> {
73
83
  readonly id: string;
@@ -75,6 +85,8 @@ export interface Tool<Input = unknown, Output = unknown> {
75
85
  readonly inputSchema: z.ZodType;
76
86
  readonly jsonSchema: Record<string, unknown>;
77
87
  readonly executionMode: ToolExecutionMode;
88
+ /** See {@link ToolDefinition.describeCall}. Absent when the tool declares none. */
89
+ describeCall?(input: Input): string;
78
90
  execute(input: Input, ctx: ToolContext): Promise<Result<Output, ToolError>>;
79
91
  }
80
92
  export interface ToolDefinition<Schema extends z.ZodType, Output> {
@@ -82,6 +94,32 @@ export interface ToolDefinition<Schema extends z.ZodType, Output> {
82
94
  readonly description: string;
83
95
  readonly inputSchema: Schema;
84
96
  readonly executionMode?: ToolExecutionMode;
97
+ /**
98
+ * One line naming what THIS call is doing — the call-time counterpart of
99
+ * `description`. `description` self-describes the tool at attach time, so an
100
+ * agent knows what it holds; `describeCall` self-describes one invocation, so
101
+ * a surface can render four `update_working_memory` calls as four distinct
102
+ * lines instead of four identical chips.
103
+ *
104
+ * It lives here, beside `inputSchema`, because that is the one place the
105
+ * compiler checks the two against each other. A host-side formatter reading
106
+ * `input.path` by string key breaks silently the day the schema moves; this
107
+ * one fails to build. The tool author writes it because the tool author knows
108
+ * which field matters.
109
+ *
110
+ * Synchronous and pure — no I/O, no ambient state. Dispatch is what the user
111
+ * waits on, and a description must never be able to fail a call: the loop
112
+ * validates the input against `inputSchema` first, guards the call, and drops
113
+ * the detail on any failure (see the tool-call-detail capability).
114
+ *
115
+ * Return whatever reads best. Normalization — one line, control characters
116
+ * removed, secrets redacted, length capped — happens once at the emit site,
117
+ * never here.
118
+ *
119
+ * OPTIONAL, and absence is a normal state: a tool without it is observable
120
+ * exactly as it is today. The hook never reaches the model.
121
+ */
122
+ describeCall?(input: z.infer<Schema>): string;
85
123
  execute(input: z.infer<Schema>, ctx: ToolContext): Promise<Result<Output, ToolError>>;
86
124
  }
87
125
  /**
@@ -8,7 +8,7 @@
8
8
  * captures its deps and calls `defineTool`.
9
9
  *
10
10
  * `ToolContext` carries only request-scoped values every tool may need
11
- * (`invocationId`, `session`, `signal`, `emit`, `runStep`, `ask`) — no pool, no sandbox, no
11
+ * (`invocationId`, `session`, `signal`, `emit`, `runStep`, `ask`, `turnUsage`) — no pool, no sandbox, no
12
12
  * logger. The error contract: an expected
13
13
  * outcome ("not found", "no results") stays in the ok channel as a data
14
14
  * variant (`ok({ found: false })`); an unexpected failure is an `err(ToolError)`
@@ -44,6 +44,9 @@ export function defineTool(def) {
44
44
  inputSchema: def.inputSchema,
45
45
  jsonSchema,
46
46
  executionMode,
47
+ // Spread rather than assigned: a tool that declares no hook carries no
48
+ // `describeCall` key at all, so "has a hook" is one property check.
49
+ ...(def.describeCall === undefined ? {} : { describeCall: def.describeCall }),
47
50
  execute: def.execute,
48
51
  };
49
52
  }
@@ -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