@inflexa-ai/harness 0.14.2 → 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 (117) 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 +24 -2
  45. package/dist/index.js +27 -0
  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.js +91 -16
  67. package/dist/providers/streaming-chat.js +5 -0
  68. package/dist/providers/types.d.ts +23 -0
  69. package/dist/providers/types.js +1 -0
  70. package/dist/runtime/assemble.d.ts +25 -8
  71. package/dist/runtime/assemble.js +7 -4
  72. package/dist/sandbox/sandbox-step-translate.d.ts +22 -1
  73. package/dist/sandbox/sandbox-step-translate.js +23 -36
  74. package/dist/schemas/run-synthesis.d.ts +2 -2
  75. package/dist/state/index.d.ts +2 -0
  76. package/dist/state/index.js +3 -0
  77. package/dist/state/init.js +28 -0
  78. package/dist/state/purge-analysis.d.ts +74 -0
  79. package/dist/state/purge-analysis.js +205 -0
  80. package/dist/state/vector-store.d.ts +6 -0
  81. package/dist/state/vector-store.js +22 -2
  82. package/dist/tasks/data-profile-activity.d.ts +14 -2
  83. package/dist/tasks/data-profile-activity.js +1 -1
  84. package/dist/tasks/data-profile.d.ts +3 -0
  85. package/dist/tasks/data-profile.js +9 -3
  86. package/dist/tools/bio/pubmed.js +15 -0
  87. package/dist/tools/bio/search-gene.js +12 -0
  88. package/dist/tools/define-tool.d.ts +41 -3
  89. package/dist/tools/define-tool.js +4 -1
  90. package/dist/tools/detail-resolver.d.ts +35 -0
  91. package/dist/tools/detail-resolver.js +39 -0
  92. package/dist/tools/execute-analysis.js +3 -0
  93. package/dist/tools/iterate-report.d.ts +3 -0
  94. package/dist/tools/iterate-report.js +3 -0
  95. package/dist/tools/memory/update-working-memory.js +8 -0
  96. package/dist/tools/research/generate-analogy-report.d.ts +3 -0
  97. package/dist/tools/research/generate-analogy-report.js +6 -0
  98. package/dist/tools/research/generate-plan.d.ts +3 -0
  99. package/dist/tools/research/generate-plan.js +6 -0
  100. package/dist/tools/research/inspect-run.js +4 -0
  101. package/dist/tools/research/literature-reviewer.d.ts +3 -0
  102. package/dist/tools/research/literature-reviewer.js +6 -0
  103. package/dist/tools/workspace/edit-file.js +2 -0
  104. package/dist/tools/workspace/execute-command.js +25 -0
  105. package/dist/tools/workspace/grep.js +3 -0
  106. package/dist/tools/workspace/list-files.js +2 -0
  107. package/dist/tools/workspace/read-file.js +7 -0
  108. package/dist/tools/workspace/workspace-search.js +1 -0
  109. package/dist/tools/workspace/write-file.js +3 -0
  110. package/dist/workflows/execute-analysis.d.ts +7 -0
  111. package/dist/workflows/execute-analysis.js +31 -2
  112. package/dist/workflows/execute-target-assessment.d.ts +3 -0
  113. package/dist/workflows/sandbox-step.d.ts +23 -0
  114. package/dist/workflows/sandbox-step.js +33 -2
  115. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.d.ts +3 -0
  116. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.js +1 -0
  117. package/package.json +1 -1
@@ -0,0 +1,205 @@
1
+ /**
2
+ * purgeAnalysis — the single place that knows what an analysis's persisted
3
+ * Postgres footprint is.
4
+ *
5
+ * A host asks for it with an `analysisId` and nothing else: every keyed store,
6
+ * the analysis's dynamic vector-table name, and its workflow ids are all
7
+ * derivable from that id, so no caller enumerates tables or supplies a run list.
8
+ * The workflow ledger is part of the footprint, not a follow-on — the sandbox
9
+ * transcripts and run-event streams live there and are the dominant share of an
10
+ * analysis's stored bytes — and it is reached through the injected
11
+ * `WorkflowPurger` seam so the durability engine stays out of the state layer.
12
+ *
13
+ * There is no single transaction across the stages: the purge spans app tables, a
14
+ * DDL `DROP TABLE`, and the engine's system schema, so instead of faking atomicity
15
+ * every stage is idempotent and the whole operation is safe to re-run. A purge of
16
+ * an unknown or already-purged analysis therefore succeeds, reporting zeroes. Each
17
+ * stage carries the reason it sits where it does.
18
+ *
19
+ * A purge is NOT serialized against work still starting on the analysis. The
20
+ * mapping from an analysis to its workflows is read once, up front, out of
21
+ * `cortex_runs`; a run inserted after that read is outside the captured set, and
22
+ * the `cortex_runs` delete later in the same purge removes the only row that could
23
+ * ever have named it. Its workflow row and every byte cascading off it then belong
24
+ * to no analysis — no retry reaches them, because nothing is left that attributes
25
+ * them to one. Quiesce the analysis before purging it: no new runs, no new
26
+ * data-profile triggers. This module cannot observe a host's in-flight work, so it
27
+ * does not enforce that.
28
+ *
29
+ * What it does not reach, so that absent coverage is never read as delivered:
30
+ * scheduled operational workflows (they belong to no analysis and accumulate
31
+ * independently of any purge), target assessments and their annotations (a
32
+ * separate top-level entity), the shared regulatory corpus, `messages` rows whose
33
+ * thread row is already gone (nothing attributes them to an analysis), and
34
+ * workspace files on disk (the embedder owns their disposal).
35
+ */
36
+ import { errAsync, okAsync } from "neverthrow";
37
+ import { createNoopLogger } from "../lib/console-logger.js";
38
+ import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
39
+ import { searchIndexName } from "../workspace/search-config.js";
40
+ import { isSqlIdentifier } from "./vector-store.js";
41
+ /**
42
+ * The shape an `analysisId` must take to be purgeable. It is load-bearing twice
43
+ * over, and neither job survives the other being dropped:
44
+ *
45
+ * - Safe DDL interpolation. The analysis's vector-table name is derived from the
46
+ * id and interpolated into `DROP TABLE`, because an identifier cannot be a bind
47
+ * parameter. Confining the id to `[a-z0-9_-]` is what makes that derived name an
48
+ * identifier at all.
49
+ * - An unambiguous `dataprofile:` namespace. Profile workflows are found by the id
50
+ * prefix `dataprofile:{analysisId}:`, matched with `starts_with` and no
51
+ * delimiter check. An id holding a `:` makes analysis `a`'s prefix equally a
52
+ * prefix of every workflow id of an analysis named `a:x` — and each id that
53
+ * prefix returns is cancelled, then deleted with its descendants, taking another
54
+ * analysis's ledger rows and everything cascading off them. Excluding `:` is the
55
+ * only thing that keeps a purge inside the analysis it was asked for.
56
+ *
57
+ * Empty is outside the shape too: it derives the bare table prefix, which is a
58
+ * perfectly legal identifier and belongs to no analysis.
59
+ */
60
+ const PURGEABLE_ANALYSIS_ID = /^[a-z0-9_-]+$/;
61
+ /**
62
+ * The analysis-keyed deletes that follow the thread/message pair, in the order
63
+ * they run.
64
+ *
65
+ * `cortex_plans` carries its own statement, immediately ahead of the
66
+ * `cortex_analysis_state` row it hangs off. The foreign key between the two
67
+ * cascades, so on a schema that has the key the statement is redundant — but the
68
+ * table is provisioned with `CREATE TABLE IF NOT EXISTS`, which adds no
69
+ * constraint to a table that already exists, so a database whose `cortex_plans`
70
+ * predates the key never acquires one. Resting on the cascade alone would make
71
+ * the completeness of a purge contingent on a constraint being present, and would
72
+ * silently leave the plans behind wherever it is not. The statement removes the
73
+ * rows either way; the cascade stays a backstop behind it.
74
+ */
75
+ const ANALYSIS_KEYED_DELETES = [
76
+ { op: "purgeAnalysis.artifacts", sql: "DELETE FROM cortex_artifacts WHERE analysis_id = $1" },
77
+ { op: "purgeAnalysis.stepExecutions", sql: "DELETE FROM cortex_step_executions WHERE analysis_id = $1" },
78
+ { op: "purgeAnalysis.runs", sql: "DELETE FROM cortex_runs WHERE analysis_id = $1" },
79
+ { op: "purgeAnalysis.workingMemory", sql: "DELETE FROM cortex_working_memory WHERE analysis_id = $1" },
80
+ { op: "purgeAnalysis.asks", sql: "DELETE FROM cortex_asks WHERE analysis_id = $1" },
81
+ { op: "purgeAnalysis.askGrants", sql: "DELETE FROM cortex_ask_grants WHERE analysis_id = $1" },
82
+ { op: "purgeAnalysis.plans", sql: "DELETE FROM cortex_plans WHERE analysis_id = $1" },
83
+ { op: "purgeAnalysis.analysisState", sql: "DELETE FROM cortex_analysis_state WHERE analysis_id = $1" },
84
+ ];
85
+ /**
86
+ * A refusal raised before any stage runs, so nothing is cancelled, no workflow row
87
+ * and no `cortex_*` row is gone, and a retry answers the same way at the same cost.
88
+ * `op` names the check that raised it.
89
+ */
90
+ function refuse(op, message) {
91
+ return errAsync({ type: "mutation_failed", op, cause: new Error(message) });
92
+ }
93
+ /** Run one delete on the transaction's client and report how many rows it took. */
94
+ function deleteRows(client, op, text, analysisId) {
95
+ return tryMutation(op, async () => {
96
+ const { rowCount } = await client.query({ text, values: [analysisId] });
97
+ return rowCount ?? 0;
98
+ });
99
+ }
100
+ export function createAnalysisPurge({ pool, workflows, logger: injected }) {
101
+ const logger = (injected ?? createNoopLogger()).named("purge-analysis");
102
+ /**
103
+ * Every workflow id the analysis can still be traced to, read while the rows
104
+ * that record them are all still on disk. `cortex_runs.run_id` IS the parent
105
+ * workflow id, and those rows together with the `dataprofile:{analysisId}:` id
106
+ * namespace are the ONLY mapping from an analysis to its workflows: a delete
107
+ * of `cortex_runs` that lands before this read completes strands the largest
108
+ * part of the footprint permanently out of reach.
109
+ */
110
+ const collectWorkflowIds = (analysisId) => tryQuery("purgeAnalysis.runWorkflowIds", async () => {
111
+ const { rows } = await pool.query({
112
+ text: "SELECT run_id FROM cortex_runs WHERE analysis_id = $1",
113
+ values: [analysisId],
114
+ });
115
+ return rows.map((row) => row.run_id);
116
+ }).andThen((runIds) => workflows.findByIdPrefix(`dataprofile:${analysisId}:`).map((profileIds) => {
117
+ logger.debug("collected workflow identity", { analysisId, runs: runIds.length, dataProfiles: profileIds.length });
118
+ // The two id sources cannot overlap, but collapsing duplicates keeps a
119
+ // repeated id out of the reclaimed count regardless.
120
+ return [...new Set([...runIds, ...profileIds])];
121
+ }));
122
+ /**
123
+ * One transaction for the whole `cortex_*` stage, so a mid-stage failure leaves
124
+ * no half-deleted analysis behind.
125
+ */
126
+ const deleteCortexRows = (analysisId) => withTransaction(pool, "purgeAnalysis.cortexRows", (client) =>
127
+ // `messages` has no `analysis_id` column and no foreign key: the only
128
+ // route from an analysis to its messages is a join through its thread
129
+ // rows. So the messages go first and the thread rows that name them go
130
+ // second — reversed, the messages become unattributable and unreachable
131
+ // by any later reclamation. No `deleted_at` filter on either: an archived
132
+ // thread is still the analysis's, and a purge takes it too.
133
+ deleteRows(client, "purgeAnalysis.messages", `DELETE FROM messages
134
+ WHERE thread_id IN (SELECT thread_id FROM cortex_analysis_threads WHERE analysis_id = $1)`, analysisId)
135
+ .andThen((messages) => deleteRows(client, "purgeAnalysis.threads", "DELETE FROM cortex_analysis_threads WHERE analysis_id = $1", analysisId).map((threads) => ({
136
+ threads,
137
+ messages,
138
+ })))
139
+ // Folded so the first failure short-circuits the rest. Submitting them
140
+ // together would not: the transaction is aborted the moment one raises,
141
+ // every statement behind it comes back `25P02`, and the `op` naming the
142
+ // delete that actually broke is buried under theirs.
143
+ .andThen((counts) => ANALYSIS_KEYED_DELETES.reduce((stage, { op, sql }) => stage.andThen(() => deleteRows(client, op, sql, analysisId).map(() => undefined)), okAsync(undefined)).map(() => counts)));
144
+ /**
145
+ * `vectorIndexDropped` is established by a `to_regclass` probe taken
146
+ * immediately before the drop, because `DROP TABLE IF EXISTS` reports nothing
147
+ * about whether it found anything. The probe resolves the name exactly as the
148
+ * drop does — both go through `search_path`, and the probe is handed the same
149
+ * double-quoted form the drop interpolates, since `to_regclass` parses its
150
+ * argument as an SQL identifier and would fold an unquoted one to lower case.
151
+ * The drop runs regardless of what the probe saw, so the window between them can
152
+ * at worst misreport the flag for an analysis already being destroyed, never
153
+ * leave a table behind.
154
+ *
155
+ * `indexName` arrives already accepted at the entry point; that is the whole
156
+ * warrant for interpolating it into the DDL below.
157
+ */
158
+ const dropVectorIndex = (indexName) => tryQuery("purgeAnalysis.vectorIndexPresent", async () => {
159
+ const { rows } = await pool.query({
160
+ text: "SELECT to_regclass($1::text) IS NOT NULL AS present",
161
+ values: [`"${indexName}"`],
162
+ });
163
+ return rows[0]?.present ?? false;
164
+ }).andThen((present) => tryMutation("purgeAnalysis.dropVectorIndex", async () => {
165
+ await pool.query(`DROP TABLE IF EXISTS "${indexName}"`);
166
+ }).map(() => present));
167
+ const purgeAnalysis = (analysisId) => {
168
+ if (!PURGEABLE_ANALYSIS_ID.test(analysisId)) {
169
+ return refuse("purgeAnalysis.analysisId", `purge-analysis: refusing unpurgeable analysis id "${analysisId}"`);
170
+ }
171
+ const indexName = searchIndexName(analysisId);
172
+ // The id's shape already settles the derived name's characters; what it cannot
173
+ // settle is how long the derivation makes it. This is the same guard the vector
174
+ // store applies to the same name on the write side, so the accepted shape
175
+ // cannot drift between the two, and a name Postgres would silently truncate
176
+ // onto another analysis's table is refused rather than dropped.
177
+ if (!isSqlIdentifier(indexName)) {
178
+ return refuse("purgeAnalysis.vectorIndexName", `purge-analysis: unsafe vector index name "${indexName}"`);
179
+ }
180
+ return (collectWorkflowIds(analysisId)
181
+ // Cancel before delete. Removing the status row of a running workflow
182
+ // does not stop the executor running it, and that executor goes on
183
+ // writing rows behind the delete — re-materializing exactly the orphans
184
+ // the purge exists to remove. A failed cancel leaves that risk in place,
185
+ // so it ends the purge rather than being absorbed into a completeness
186
+ // claim the operation can no longer make.
187
+ .andThen((ids) => workflows.cancel(ids).map(() => ids))
188
+ // Workflow rows go before the `cortex_*` ledger that names them: a failure
189
+ // here leaves the id mapping on disk and a retry finds it, whereas the
190
+ // reverse order turns one transient failure into a permanent orphan.
191
+ // Descendants are included because a run's child step workflows carry the
192
+ // sandbox transcripts and are reachable only through their parent.
193
+ .andThen((ids) => workflows.deleteWorkflows(ids, true))
194
+ .andThen((workflowsTargeted) => deleteCortexRows(analysisId).map((counts) => ({ ...counts, workflows: workflowsTargeted })))
195
+ // The vector table is dropped after the transaction rather than inside
196
+ // it: `DROP TABLE` takes an ACCESS EXCLUSIVE lock, and enlisting it would
197
+ // hold that lock for the duration of every row delete above.
198
+ .andThen((reclaimed) => dropVectorIndex(indexName).map((vectorIndexDropped) => ({ ...reclaimed, vectorIndexDropped })))
199
+ .map((outcome) => {
200
+ logger.info("purged analysis", { analysisId, ...outcome });
201
+ return outcome;
202
+ }));
203
+ };
204
+ return { purgeAnalysis };
205
+ }
@@ -15,6 +15,12 @@
15
15
  import { ResultAsync } from "neverthrow";
16
16
  import type { Pool } from "pg";
17
17
  import { type DbError } from "../lib/db-result.js";
18
+ /**
19
+ * Whether `name` may be interpolated into a statement as an identifier. Shared
20
+ * with the reclaim side, which guards the same derived names against the same
21
+ * shape — one invariant, one place to read it.
22
+ */
23
+ export declare function isSqlIdentifier(name: string): boolean;
18
24
  export interface CreateIndexInput {
19
25
  readonly indexName: string;
20
26
  readonly dimension: number;
@@ -14,9 +14,29 @@
14
14
  */
15
15
  import { okAsync } from "neverthrow";
16
16
  import { tryMutation, tryQuery } from "../lib/db-result.js";
17
- const IDENT = /^[a-z_][a-z0-9_]*$/;
17
+ /**
18
+ * The identifier shape and length the harness may interpolate into a statement.
19
+ * An identifier cannot be a bind parameter, so a name reaching SQL has to be
20
+ * checked rather than bound, and lower-snake is the whole of what the harness ever
21
+ * derives. The bound is as load-bearing as the shape: Postgres keeps only the
22
+ * first 63 bytes of an identifier and truncates the rest silently, so two longer
23
+ * names sharing a 63-byte prefix resolve to one table — a write that lands in
24
+ * another resource's index, and a drop that takes it. The shape is ASCII-only, so
25
+ * a name that matches it has one byte per character and `length` is the byte count
26
+ * the limit is measured in.
27
+ */
28
+ const SQL_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
29
+ const MAX_SQL_IDENTIFIER_BYTES = 63;
30
+ /**
31
+ * Whether `name` may be interpolated into a statement as an identifier. Shared
32
+ * with the reclaim side, which guards the same derived names against the same
33
+ * shape — one invariant, one place to read it.
34
+ */
35
+ export function isSqlIdentifier(name) {
36
+ return SQL_IDENTIFIER.test(name) && name.length <= MAX_SQL_IDENTIFIER_BYTES;
37
+ }
18
38
  function assertIdent(name) {
19
- if (!IDENT.test(name)) {
39
+ if (!isSqlIdentifier(name)) {
20
40
  throw new Error(`vector-store: unsafe index name "${name}"`);
21
41
  }
22
42
  }
@@ -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({