@inflexa-ai/harness 0.46.0 → 0.47.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 (280) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/conversation-agent.d.ts +3 -8
  3. package/dist/agents/conversation-agent.js +24 -4
  4. package/dist/agents/report-session-agent.d.ts +11 -14
  5. package/dist/agents/report-session-agent.js +30 -17
  6. package/dist/agents/sandbox/shared.d.ts +18 -13
  7. package/dist/agents/sandbox/shared.js +42 -27
  8. package/dist/agents/sandbox/types.d.ts +1 -1
  9. package/dist/agents/sandbox-catalog.d.ts +2 -0
  10. package/dist/agents/sandbox-catalog.js +30 -1
  11. package/dist/app/chat-turn-workflow.js +5 -6
  12. package/dist/app/synthesize-run.d.ts +10 -6
  13. package/dist/app/synthesize-run.js +4 -2
  14. package/dist/contracts/analogy-report.d.ts +46 -2
  15. package/dist/contracts/analogy-report.js +31 -25
  16. package/dist/contracts/chat-parts.d.ts +3 -14
  17. package/dist/contracts/report-reference.d.ts +2 -1
  18. package/dist/contracts/report-reference.js +2 -1
  19. package/dist/contracts/schemas/chat-parts.d.ts +0 -27
  20. package/dist/contracts/schemas/chat-parts.js +1 -3
  21. package/dist/execution/run-canceler.d.ts +2 -0
  22. package/dist/execution/run-canceler.js +9 -0
  23. package/dist/execution/run-synthesis.d.ts +36 -8
  24. package/dist/execution/run-synthesis.js +114 -32
  25. package/dist/index.d.ts +2 -5
  26. package/dist/index.js +3 -6
  27. package/dist/input-scan/enrich.d.ts +5 -10
  28. package/dist/input-scan/enrich.js +12 -8
  29. package/dist/lib/hooks.d.ts +9 -0
  30. package/dist/lib/hooks.js +6 -1
  31. package/dist/lib/markdown-pdf.d.ts +27 -0
  32. package/dist/lib/markdown-pdf.js +236 -0
  33. package/dist/lib/metrics.d.ts +31 -0
  34. package/dist/lib/metrics.js +64 -0
  35. package/dist/lib/pdf-text.d.ts +23 -0
  36. package/dist/lib/pdf-text.js +34 -0
  37. package/dist/lib/regulatory-corpus/fetch-fda-guidance.js +3 -5
  38. package/dist/lib/regulatory-corpus/fetch-ich.js +3 -5
  39. package/dist/loop/run-loop.d.ts +5 -0
  40. package/dist/loop/run-loop.js +30 -2
  41. package/dist/loop/sub-agent.js +5 -6
  42. package/dist/loop/tool-name-repair.d.ts +11 -0
  43. package/dist/loop/tool-name-repair.js +23 -0
  44. package/dist/loop/turn-credentials.d.ts +5 -0
  45. package/dist/loop/turn-credentials.js +17 -0
  46. package/dist/loop/types.d.ts +14 -0
  47. package/dist/memory/card-builders.d.ts +1 -1
  48. package/dist/prompts/SOUL.d.ts +6 -6
  49. package/dist/prompts/SOUL.js +22 -121
  50. package/dist/prompts/analogical-reasoner.d.ts +1 -1
  51. package/dist/prompts/analogical-reasoner.js +35 -101
  52. package/dist/prompts/briefing.d.ts +20 -11
  53. package/dist/prompts/briefing.js +36 -19
  54. package/dist/prompts/conversation.d.ts +1 -1
  55. package/dist/prompts/conversation.js +103 -383
  56. package/dist/prompts/planner.d.ts +1 -5
  57. package/dist/prompts/planner.js +45 -128
  58. package/dist/prompts/report-session.d.ts +1 -1
  59. package/dist/prompts/report-session.js +26 -57
  60. package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
  61. package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +3 -3
  62. package/dist/prompts/sandbox/cheminformatics-agent.d.ts +1 -1
  63. package/dist/prompts/sandbox/cheminformatics-agent.js +12 -47
  64. package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
  65. package/dist/prompts/sandbox/chromatin-agent.js +4 -5
  66. package/dist/prompts/sandbox/data-profiler.js +11 -34
  67. package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
  68. package/dist/prompts/sandbox/dna-methylation-agent.js +3 -1
  69. package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
  70. package/dist/prompts/sandbox/drug-repurposing-agent.js +23 -73
  71. package/dist/prompts/sandbox/enrichment-agent.d.ts +1 -1
  72. package/dist/prompts/sandbox/enrichment-agent.js +3 -1
  73. package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
  74. package/dist/prompts/sandbox/genomic-variant-agent.js +3 -4
  75. package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
  76. package/dist/prompts/sandbox/immune-profiling-agent.js +5 -4
  77. package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
  78. package/dist/prompts/sandbox/metabolomics-agent.js +3 -1
  79. package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
  80. package/dist/prompts/sandbox/microbiome-agent.js +3 -1
  81. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  82. package/dist/prompts/sandbox/multi-omics-integration-agent.js +3 -1
  83. package/dist/prompts/sandbox/multimodal-sc-agent.d.ts +1 -1
  84. package/dist/prompts/sandbox/multimodal-sc-agent.js +3 -1
  85. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  86. package/dist/prompts/sandbox/network-agent.js +3 -1
  87. package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
  88. package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +4 -7
  89. package/dist/prompts/sandbox/proteomics-agent.d.ts +1 -1
  90. package/dist/prompts/sandbox/proteomics-agent.js +3 -1
  91. package/dist/prompts/sandbox/scientific-executor.d.ts +1 -1
  92. package/dist/prompts/sandbox/scientific-executor.js +0 -12
  93. package/dist/prompts/sandbox/single-cell-agent.d.ts +1 -1
  94. package/dist/prompts/sandbox/single-cell-agent.js +3 -1
  95. package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
  96. package/dist/prompts/sandbox/spatial-omics-agent.js +3 -1
  97. package/dist/prompts/sandbox/statistical-modeling-agent.d.ts +1 -1
  98. package/dist/prompts/sandbox/statistical-modeling-agent.js +4 -4
  99. package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
  100. package/dist/prompts/sandbox/translational-safety-agent.js +10 -29
  101. package/dist/prompts/sandbox-standards.d.ts +15 -18
  102. package/dist/prompts/sandbox-standards.js +96 -305
  103. package/dist/prompts/synthesis-agent.d.ts +1 -1
  104. package/dist/prompts/synthesis-agent.js +24 -90
  105. package/dist/provenance/collector.d.ts +2 -5
  106. package/dist/provenance/collector.js +2 -5
  107. package/dist/provenance/seam.d.ts +36 -1
  108. package/dist/provenance/workspace-reads.d.ts +36 -0
  109. package/dist/provenance/workspace-reads.js +33 -0
  110. package/dist/providers/request-headers.js +12 -3
  111. package/dist/report-model/production-resolver.d.ts +22 -52
  112. package/dist/report-model/production-resolver.js +43 -112
  113. package/dist/report-render/chart-export.d.ts +9 -0
  114. package/dist/report-render/chart-export.js +1 -1
  115. package/dist/report-render/views/chart-view.d.ts +1 -0
  116. package/dist/report-render/views/chart-view.js +1 -0
  117. package/dist/report-render/views/lineage.d.ts +1 -0
  118. package/dist/report-render/views/page-view.d.ts +1 -0
  119. package/dist/report-render/views/page-view.js +1 -0
  120. package/dist/report-render/views/prose.d.ts +1 -0
  121. package/dist/report-render/views/prose.js +1 -0
  122. package/dist/report-render/views/references-view.d.ts +1 -0
  123. package/dist/report-render/views/values.d.ts +1 -0
  124. package/dist/report-render/views/values.js +1 -0
  125. package/dist/runtime/assemble.d.ts +20 -9
  126. package/dist/runtime/assemble.js +47 -38
  127. package/dist/sandbox/client.d.ts +5 -3
  128. package/dist/sandbox/create-sandbox.d.ts +7 -12
  129. package/dist/sandbox/create-sandbox.js +15 -37
  130. package/dist/sandbox/docker-client.d.ts +1 -3
  131. package/dist/sandbox/docker-client.js +3 -5
  132. package/dist/sandbox/exec-outcome.d.ts +11 -11
  133. package/dist/sandbox/exec-outcome.js +22 -22
  134. package/dist/sandbox/exec.d.ts +5 -0
  135. package/dist/sandbox/exec.js +9 -1
  136. package/dist/sandbox/identity.d.ts +4 -16
  137. package/dist/sandbox/identity.js +5 -21
  138. package/dist/sandbox/k8s-client.d.ts +0 -1
  139. package/dist/sandbox/k8s-client.js +8 -13
  140. package/dist/sandbox/lease-names.d.ts +6 -0
  141. package/dist/sandbox/lease-names.js +12 -0
  142. package/dist/sandbox/leases.d.ts +105 -0
  143. package/dist/sandbox/leases.js +519 -0
  144. package/dist/sandbox/liveness.d.ts +5 -2
  145. package/dist/sandbox/mount-plan.d.ts +10 -1
  146. package/dist/sandbox/mount-plan.js +7 -1
  147. package/dist/sandbox/reconciler.d.ts +58 -0
  148. package/dist/sandbox/reconciler.js +153 -0
  149. package/dist/sandbox/sandbox-step-translate.d.ts +1 -1
  150. package/dist/sandbox/types.d.ts +25 -18
  151. package/dist/sandbox/types.js +10 -4
  152. package/dist/schemas/plan-schemas.d.ts +0 -21
  153. package/dist/schemas/plan-schemas.js +2 -3
  154. package/dist/schemas/run-synthesis.d.ts +2 -2
  155. package/dist/schemas/run-synthesis.js +3 -3
  156. package/dist/schemas/validate-plan.d.ts +1 -14
  157. package/dist/schemas/validate-plan.js +5 -24
  158. package/dist/schemas/workflow-state.d.ts +3 -32
  159. package/dist/schemas/workflow-state.js +4 -13
  160. package/dist/state/index.d.ts +0 -1
  161. package/dist/state/index.js +0 -1
  162. package/dist/state/migrations/20261030120000_lease_the_sandboxes.d.ts +7 -0
  163. package/dist/state/migrations/20261030120000_lease_the_sandboxes.js +34 -0
  164. package/dist/state/migrations/20261030120100_drop_the_report_derivations.d.ts +5 -0
  165. package/dist/state/migrations/20261030120100_drop_the_report_derivations.js +9 -0
  166. package/dist/state/migrations/20261030120200_keep_the_report_derivations.d.ts +6 -0
  167. package/dist/state/migrations/20261030120200_keep_the_report_derivations.js +10 -0
  168. package/dist/state/migrations/index.js +6 -0
  169. package/dist/state/purge-analysis.d.ts +4 -2
  170. package/dist/state/purge-analysis.js +22 -11
  171. package/dist/state/sandbox-leases.d.ts +81 -0
  172. package/dist/state/sandbox-leases.js +247 -0
  173. package/dist/state/schema.d.ts +1 -13
  174. package/dist/state/schema.js +1 -6
  175. package/dist/state/step-executions.d.ts +0 -2
  176. package/dist/state/step-executions.js +1 -4
  177. package/dist/tasks/data-profile.d.ts +40 -2
  178. package/dist/tasks/data-profile.js +89 -87
  179. package/dist/testing.d.ts +2 -0
  180. package/dist/testing.js +6 -1
  181. package/dist/tools/ad-hoc-router.d.ts +1 -6
  182. package/dist/tools/ad-hoc-router.js +5 -48
  183. package/dist/tools/bio/comptox.js +1 -1
  184. package/dist/tools/bio/drug-gene-interactions.js +1 -1
  185. package/dist/tools/bio/gene-disease-evidence.js +1 -1
  186. package/dist/tools/bio/keys.d.ts +1 -1
  187. package/dist/tools/bio/pubmed.js +31 -0
  188. package/dist/tools/bio/search-gene.js +17 -0
  189. package/dist/tools/bio/search-geo-datasets.js +13 -0
  190. package/dist/tools/bio/search-regulatory-approvals.js +1 -1
  191. package/dist/tools/define-tool.d.ts +25 -2
  192. package/dist/tools/define-tool.js +11 -1
  193. package/dist/tools/display/show-user.js +1 -1
  194. package/dist/tools/execute-analysis.d.ts +1 -3
  195. package/dist/tools/execute-analysis.js +5 -33
  196. package/dist/tools/report-session/index.d.ts +0 -1
  197. package/dist/tools/report-session/index.js +0 -1
  198. package/dist/tools/report-session/script-tools.d.ts +30 -0
  199. package/dist/tools/report-session/script-tools.js +177 -0
  200. package/dist/tools/research/generate-analogy-report.d.ts +16 -9
  201. package/dist/tools/research/generate-analogy-report.js +111 -49
  202. package/dist/tools/research/generate-plan.d.ts +102 -7
  203. package/dist/tools/research/generate-plan.js +74 -43
  204. package/dist/tools/research/index.d.ts +4 -3
  205. package/dist/tools/research/index.js +4 -3
  206. package/dist/tools/research/inspect-run.d.ts +0 -1
  207. package/dist/tools/research/inspect-run.js +5 -10
  208. package/dist/tools/research/search-arxiv.js +15 -0
  209. package/dist/tools/research/search-github-repos.js +13 -0
  210. package/dist/tools/research/search-semantic-scholar.js +16 -0
  211. package/dist/tools/research/synthesis-lookups.d.ts +21 -0
  212. package/dist/tools/research/synthesis-lookups.js +29 -0
  213. package/dist/tools/research/web-fetch.d.ts +1 -1
  214. package/dist/tools/research/web-fetch.js +14 -12
  215. package/dist/tools/sandbox/lease-tools.d.ts +32 -0
  216. package/dist/tools/sandbox/lease-tools.js +60 -0
  217. package/dist/tools/script/codemode-worker.d.ts +5 -0
  218. package/dist/tools/script/codemode-worker.js +5 -0
  219. package/dist/tools/script/file-tools.d.ts +100 -0
  220. package/dist/tools/script/file-tools.js +377 -0
  221. package/dist/tools/script/metrics.d.ts +21 -0
  222. package/dist/tools/script/metrics.js +38 -0
  223. package/dist/tools/script/quickjs.wasm +0 -0
  224. package/dist/tools/script/run-script.d.ts +76 -0
  225. package/dist/tools/script/run-script.js +326 -0
  226. package/dist/tools/script/scratch.d.ts +81 -0
  227. package/dist/tools/script/scratch.js +142 -0
  228. package/dist/tools/start-report-session.js +1 -1
  229. package/dist/tools/workspace/edit-file.js +2 -1
  230. package/dist/tools/workspace/execute-command.d.ts +9 -13
  231. package/dist/tools/workspace/execute-command.js +23 -19
  232. package/dist/tools/workspace/file-stat.js +1 -1
  233. package/dist/tools/workspace/grep.d.ts +5 -2
  234. package/dist/tools/workspace/grep.js +19 -10
  235. package/dist/tools/workspace/mutator.d.ts +48 -4
  236. package/dist/tools/workspace/mutator.js +43 -12
  237. package/dist/tools/workspace/read-file.d.ts +5 -2
  238. package/dist/tools/workspace/read-file.js +15 -11
  239. package/dist/tools/workspace/read-surface.d.ts +17 -0
  240. package/dist/tools/workspace/read-surface.js +17 -0
  241. package/dist/tools/workspace/result-bounds.d.ts +1 -0
  242. package/dist/tools/workspace/run-exec.d.ts +12 -13
  243. package/dist/tools/workspace/run-exec.js +13 -13
  244. package/dist/tools/workspace/scan-inputs.d.ts +2 -5
  245. package/dist/tools/workspace/scan-inputs.js +2 -3
  246. package/dist/tools/workspace/show-file.js +1 -1
  247. package/dist/tools/workspace/show-plan.js +4 -3
  248. package/dist/tools/workspace/write-file.js +2 -1
  249. package/dist/workflows/execute-analysis-scheduler.d.ts +0 -26
  250. package/dist/workflows/execute-analysis-scheduler.js +12 -32
  251. package/dist/workflows/execute-analysis.d.ts +17 -35
  252. package/dist/workflows/execute-analysis.js +13 -107
  253. package/dist/workflows/pause.js +29 -22
  254. package/dist/workflows/sandbox-step.d.ts +16 -31
  255. package/dist/workflows/sandbox-step.js +36 -71
  256. package/dist/workflows/test/sql-writes.d.ts +0 -1
  257. package/dist/workflows/test/sql-writes.js +0 -2
  258. package/dist/workspace/filesystem.d.ts +2 -2
  259. package/dist/workspace/filesystem.js +10 -5
  260. package/dist/workspace/paths.d.ts +19 -1
  261. package/dist/workspace/paths.js +22 -1
  262. package/package.json +6 -3
  263. package/dist/prompts/literature-reviewer.d.ts +0 -1
  264. package/dist/prompts/literature-reviewer.js +0 -114
  265. package/dist/sandbox/reaper.d.ts +0 -81
  266. package/dist/sandbox/reaper.js +0 -161
  267. package/dist/sandbox/step-sandbox.d.ts +0 -47
  268. package/dist/sandbox/step-sandbox.js +0 -53
  269. package/dist/state/active-sandboxes.d.ts +0 -33
  270. package/dist/state/active-sandboxes.js +0 -65
  271. package/dist/tasks/extract-values-script.d.ts +0 -64
  272. package/dist/tasks/extract-values-script.js +0 -180
  273. package/dist/tasks/extract-values.d.ts +0 -77
  274. package/dist/tasks/extract-values.js +0 -146
  275. package/dist/tools/report-session/derive-table.d.ts +0 -164
  276. package/dist/tools/report-session/derive-table.js +0 -419
  277. package/dist/tools/research/literature-reviewer.d.ts +0 -42
  278. package/dist/tools/research/literature-reviewer.js +0 -107
  279. package/dist/workflows/metrics.d.ts +0 -23
  280. package/dist/workflows/metrics.js +0 -38
@@ -0,0 +1,326 @@
1
+ /**
2
+ * `run_script` — the agent writes one JavaScript script that calls its script tools as functions.
3
+ *
4
+ * The script runs in QuickJS (`@earendil-works/pi-codemode`), a WASM VM in a worker thread. The VM has no files, no
5
+ * network, and no timers. Its only capability is a call of a script tool, and each call runs the `execute` of that
6
+ * tool with the context of the `run_script` call. Only what the script prints or returns reaches the model, thus a
7
+ * script can join and filter large lookup results and tables before the model reads them.
8
+ *
9
+ * The whole script is one durable step: a replay returns the cached result and runs no lookup again. A step that
10
+ * did not complete runs the whole script again, thus a script tool that writes must accept the same write again.
11
+ *
12
+ * Each nested call streams its start and its end under the `run_script` call, thus a surface shows what the script
13
+ * does while it runs. With a scratch directory, the script is saved before it runs, each derived file is shown to
14
+ * the user in one file card with the script, and the files that the script read and wrote reach provenance.
15
+ */
16
+ import { CodemodeSandbox, loadQuickJSWasm, renderToolSample, renderToolSignature, schemaToType, } from "@earendil-works/pi-codemode";
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+ import { ok } from "neverthrow";
19
+ import { z } from "zod";
20
+ import { createSubAgentActivityTracker } from "../../contracts/subagent-activity.js";
21
+ import { createNoopLogger } from "../../lib/console-logger.js";
22
+ import { parseJsonAs } from "../../lib/parse-json.js";
23
+ import { parseUnknownRecord, UnknownArraySchema } from "../../lib/unknown-containers.js";
24
+ import { computeDetail, computeResultDetail } from "../../loop/tool-detail.js";
25
+ import { buildFileReferenceCardData, MAX_FILES } from "../../memory/card-builders.js";
26
+ import { defineTool, readToolCallRecord, readToolResultImages, withToolCallRecord, } from "../define-tool.js";
27
+ import { ScriptFileEffectSchema } from "./file-tools.js";
28
+ import { recordScriptRun } from "./metrics.js";
29
+ export const RUN_SCRIPT_TOOL_ID = "run_script";
30
+ /** The deadline of one script, for each lookup in it together. */
31
+ const SCRIPT_TIMEOUT_MS = 180_000;
32
+ /** The heap of the VM. A script holds lookup results and tables of up to the read cap, not datasets. */
33
+ const SCRIPT_MEMORY_BYTES = 256 * 1024 * 1024;
34
+ /** The text of a result that reaches the model. A longer output keeps its head and its tail. */
35
+ const MAX_RESULT_CHARS = 24_000;
36
+ const inputSchema = z.object({
37
+ description: z.string().max(200).optional().describe("One short line that tells the user what the script does, for example 'Top 20 genes by padj'."),
38
+ code: z
39
+ .string()
40
+ .min(1)
41
+ .describe("The body of an async JavaScript function. Call a tool as `await tools.<name>({...})`, print with `text(value)` or `console.log`, and `return` a value."),
42
+ });
43
+ /** The first sentence of a description, for the catalog line of a tool. */
44
+ function firstSentence(description) {
45
+ const line = description.split("\n")[0] ?? "";
46
+ const stop = line.indexOf(". ");
47
+ return stop === -1 ? line : line.slice(0, stop + 1);
48
+ }
49
+ function bounded(text) {
50
+ if (text.length <= MAX_RESULT_CHARS)
51
+ return text;
52
+ const half = MAX_RESULT_CHARS / 2;
53
+ return `${text.slice(0, half)}\n[... ${text.length - MAX_RESULT_CHARS} characters cut ...]\n${text.slice(-half)}`;
54
+ }
55
+ /** The JSON form of a tool result: the plain data, without the symbol keys of the loop. */
56
+ function plainJson(value) {
57
+ return value === undefined ? null : parseJsonAs(JSON.stringify(value), z.json());
58
+ }
59
+ function describeTool(tool) {
60
+ return renderToolSample({ name: tool.id, description: tool.description, inputSchema: tool.jsonSchema, outputSchema: tool.outputJsonSchema });
61
+ }
62
+ function issuesOf(error) {
63
+ return error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
64
+ }
65
+ /** Keep the files of the call record of one nested call. A record of another shape carries no file. */
66
+ function collectEffect(state, value) {
67
+ const effect = ScriptFileEffectSchema.safeParse(readToolCallRecord(value));
68
+ if (!effect.success)
69
+ return;
70
+ state.reads.push(...(effect.data.reads ?? []));
71
+ state.writes.push(...(effect.data.writes ?? []));
72
+ }
73
+ /**
74
+ * A script tool as the VM calls it. Each call gets its own invocation id, `<id of run_script>:<n>` in call order: a
75
+ * tool keys what it stores by its invocation id (`web_fetch` keeps the cut text of a page under it), thus two calls of
76
+ * one script must not share it. The script is one step, thus a replay calls nothing and the ids stay as recorded.
77
+ *
78
+ * The script gets the JSON of a result, and the JSON has no pictures. Thus a result with pictures is a failed call: the
79
+ * script would otherwise read the result as complete.
80
+ */
81
+ function scriptToolOf(tool, ctx, state, log, inCallContext) {
82
+ return {
83
+ name: tool.id,
84
+ description: tool.description,
85
+ inputSchema: tool.jsonSchema,
86
+ outputSchema: tool.outputJsonSchema,
87
+ execute: (args, options) => inCallContext(() => runNestedCall(tool, ctx, state, log, args, options.signal)),
88
+ };
89
+ }
90
+ async function runNestedCall(tool, ctx, state, log, args, signal) {
91
+ const invocationId = state.nextInvocationId();
92
+ const detail = computeDetail(tool, args, log);
93
+ const started = performance.now();
94
+ const startFrame = { type: "tool-started", source: state.source, toolUseId: invocationId, name: tool.id, input: args, detail };
95
+ state.observe(startFrame);
96
+ await ctx.emit(startFrame);
97
+ const finish = async (outcome, finishedDetail) => {
98
+ state.calls.push({ tool: tool.id, ok: outcome === "ok" });
99
+ const endFrame = {
100
+ type: "tool-finished",
101
+ source: state.source,
102
+ toolUseId: invocationId,
103
+ name: tool.id,
104
+ outcome,
105
+ durationMs: Math.round(performance.now() - started),
106
+ detail: finishedDetail,
107
+ };
108
+ state.observe(endFrame);
109
+ await ctx.emit(endFrame);
110
+ };
111
+ const parsed = tool.inputSchema.safeParse(args);
112
+ if (!parsed.success) {
113
+ await finish("error", detail);
114
+ throw new Error(`${tool.id}: ${issuesOf(parsed.error)}`);
115
+ }
116
+ const result = await tool.execute(parsed.data, {
117
+ ...ctx,
118
+ invocationId,
119
+ signal: AbortSignal.any([ctx.signal, signal]),
120
+ script: { code: state.code },
121
+ });
122
+ if (result.isErr()) {
123
+ await finish("error", detail);
124
+ throw new Error(`${tool.id}: ${result.error.error}`);
125
+ }
126
+ if (readToolResultImages(result.value).length > 0) {
127
+ await finish("error", detail);
128
+ throw new Error(`${tool.id}: the result holds pictures, and a script cannot carry pictures. Call ${tool.id} directly, not from a script.`);
129
+ }
130
+ collectEffect(state, result.value);
131
+ if (tool.foldCallRecord !== undefined) {
132
+ const record = readToolCallRecord(result.value);
133
+ if (record !== undefined)
134
+ state.nested.push({ tool: tool.id, record });
135
+ }
136
+ await finish("ok", computeResultDetail(tool, parsed.data, result.value, log) ?? detail);
137
+ return plainJson(result.value);
138
+ }
139
+ /** A JSON schema without its prose: the types and the constraints stay, the descriptions and the examples go. */
140
+ function withoutProse(schema) {
141
+ const array = UnknownArraySchema.safeParse(schema);
142
+ if (array.success)
143
+ return array.data.map(withoutProse);
144
+ const record = parseUnknownRecord(schema);
145
+ if (record === undefined)
146
+ return schema;
147
+ return Object.fromEntries(Object.entries(record)
148
+ .filter(([key]) => key !== "description" && key !== "examples")
149
+ .map(([key, value]) => [key, withoutProse(value)]));
150
+ }
151
+ /** The longest result type that the catalog shows. A longer type shows as `unknown`, and `describe()` gives it whole. */
152
+ const MAX_CATALOG_RESULT_TYPE_CHARS = 800;
153
+ /** One catalog entry: the first sentence of the description, then the signature with no prose. */
154
+ function catalogEntry(tool) {
155
+ const output = tool.outputJsonSchema === undefined ? undefined : parseUnknownRecord(withoutProse(tool.outputJsonSchema));
156
+ const resultType = output === undefined ? "unknown" : schemaToType(output, { maxChars: MAX_CATALOG_RESULT_TYPE_CHARS });
157
+ // pi renders a signature with no result schema as `...: Promise<unknown>;`, thus the catalog puts its capped type there.
158
+ const untyped = renderToolSignature({ name: tool.id, inputSchema: parseUnknownRecord(withoutProse(tool.jsonSchema)) ?? {} });
159
+ const signature = `${untyped.slice(0, -"unknown>;".length)}${resultType}>;`;
160
+ return `/** ${firstSentence(tool.description)} */\n${signature}`;
161
+ }
162
+ /** The description of the tool: the script API, then one catalog entry for each script tool. */
163
+ function descriptionOf(tools) {
164
+ const catalog = tools.map(catalogEntry).join("\n");
165
+ return ("Run a JavaScript script that calls the tools below as functions, in a JavaScript VM on the host with no files and no network. " +
166
+ "Use it for lookups and for light work on tables: call several tools in parallel with Promise.all, filter, join, and count their results, and print only what you need. " +
167
+ "Only the printed output and the returned value come back to you.\n" +
168
+ "API: `await tools.<name>(args)` returns the JSON result of the tool and throws an Error on a failed call. " +
169
+ '`await describe("<name>")` returns the full description of a tool, with the meaning of each argument. ' +
170
+ "`text(value)` and `console.log` print. `return` gives a value. There are no timers, no fetch, and no modules.\n" +
171
+ `Tools (the result is the JSON that the tool returns):\n${catalog}`);
172
+ }
173
+ function sandboxRuntimeOf(runtime) {
174
+ const options = {};
175
+ if (runtime.wasmPath !== undefined)
176
+ options.wasm = loadQuickJSWasm(runtime.wasmPath);
177
+ if (runtime.workerUrl !== undefined)
178
+ options.workerUrl = runtime.workerUrl;
179
+ return options;
180
+ }
181
+ /** The failure of a script, for the metrics. The VM reports an exhausted heap as an error of the script. */
182
+ function failureOf(error) {
183
+ if (error.kind === "script")
184
+ return error.message.includes("out of memory") ? "memory" : "script";
185
+ return error.kind;
186
+ }
187
+ /** The card of the files that a script derived, with the script beside them. A card holds at most ten files. */
188
+ function filesCard(description, writes, script) {
189
+ const files = [...new Map(writes.map((write) => [write.path, { path: write.path }])).values()];
190
+ const room = script === undefined ? MAX_FILES : MAX_FILES - 1;
191
+ const shown = files.slice(0, room);
192
+ if (script !== undefined)
193
+ shown.push({ path: script.path, caption: "The script that derived these files." });
194
+ return buildFileReferenceCardData({ title: description ?? "Derived files", files: shown });
195
+ }
196
+ /** The `run_script` tool over the script tools of an agent. */
197
+ export function createRunScriptTool(scriptTools, runtime = {}, options = {}) {
198
+ const vm = sandboxRuntimeOf(runtime);
199
+ const byId = new Map(scriptTools.map((tool) => [tool.id, tool]));
200
+ const log = (options.logger ?? createNoopLogger()).named("run-script");
201
+ const { scratch } = options;
202
+ return defineTool({
203
+ id: RUN_SCRIPT_TOOL_ID,
204
+ description: descriptionOf(scriptTools),
205
+ inputSchema,
206
+ describeCall: ({ description }) => description,
207
+ describeResult: ({ description }, result) => {
208
+ const tools = result.calls.length === 0 ? undefined : result.calls.map((call) => call.tool).join(", ");
209
+ return description === undefined ? tools : tools === undefined ? description : `${description} (${tools})`;
210
+ },
211
+ foldCallRecord: (record) => {
212
+ if (record.run !== undefined)
213
+ scratch?.recordRun(record.run);
214
+ for (const entry of record.nested) {
215
+ try {
216
+ byId.get(entry.tool)?.foldCallRecord?.(entry.record);
217
+ }
218
+ catch (err) {
219
+ log.error("the call record of a nested call did not fold", { tool: entry.tool, ...log.errorFields(err) });
220
+ }
221
+ }
222
+ },
223
+ execute: async ({ description, code }, ctx) => {
224
+ const startedAt = performance.now();
225
+ const script = await scratch?.saveScript({ session: ctx.session, invocationId: ctx.invocationId, code });
226
+ const caller = ctx.session.provenance;
227
+ const tracker = createSubAgentActivityTracker();
228
+ // The tracker reads the call of the script first, thus each nested call belongs to it. Only a call of the
229
+ // root agent has an activity part: the tracker keeps no root for a deeper caller.
230
+ tracker.track({
231
+ type: "tool-started",
232
+ toolUseId: ctx.invocationId,
233
+ name: RUN_SCRIPT_TOOL_ID,
234
+ source: { agentId: caller.agentId, callPath: [...caller.callPath] },
235
+ }, Date.now());
236
+ const tracked = { part: null };
237
+ let started = 0;
238
+ const state = {
239
+ code,
240
+ calls: [],
241
+ reads: [],
242
+ writes: [],
243
+ nested: [],
244
+ nextInvocationId: () => `${ctx.invocationId}:${++started}`,
245
+ source: { agentId: RUN_SCRIPT_TOOL_ID, callPath: [...caller.callPath, RUN_SCRIPT_TOOL_ID], invocationId: ctx.invocationId },
246
+ observe: (frame) => {
247
+ tracked.part = tracker.track(frame, Date.now()) ?? tracked.part;
248
+ },
249
+ };
250
+ // The VM calls a tool from a message event of its worker, and such an event runs outside the async context
251
+ // of this call. A nested call runs in the snapshot of this context, thus it sees what the call sees, for
252
+ // example the durable step of the call, which DBOS keeps in an AsyncLocalStorage.
253
+ const inCallContext = AsyncLocalStorage.snapshot();
254
+ const sandbox = new CodemodeSandbox({
255
+ tools: scriptTools.map((tool) => scriptToolOf(tool, ctx, state, log, inCallContext)),
256
+ globals: [
257
+ {
258
+ name: "describe",
259
+ execute: (name) => {
260
+ const tool = typeof name === "string" ? byId.get(name) : undefined;
261
+ if (tool === undefined)
262
+ throw new Error(`no script tool ${JSON.stringify(name)}; the tools are ${[...byId.keys()].join(", ")}`);
263
+ return describeTool(tool);
264
+ },
265
+ },
266
+ ],
267
+ timeoutMs: SCRIPT_TIMEOUT_MS,
268
+ memoryLimitBytes: SCRIPT_MEMORY_BYTES,
269
+ ...vm,
270
+ });
271
+ try {
272
+ const result = await sandbox.execute(code, { signal: ctx.signal });
273
+ // A cancel of the call is no result of the script: the loop must see the abort and end the run.
274
+ ctx.signal.throwIfAborted();
275
+ const output = bounded(result.output
276
+ .filter((item) => item.type === "text")
277
+ .map((item) => item.text)
278
+ .join("\n"));
279
+ const failure = result.ok ? "none" : failureOf(result.error);
280
+ recordScriptRun(caller.agentId, failure);
281
+ log.info("a script ran", {
282
+ invocationId: ctx.invocationId,
283
+ failure,
284
+ durationMs: Math.round(performance.now() - startedAt),
285
+ calls: state.calls.length,
286
+ reads: state.reads.length,
287
+ writes: state.writes.length,
288
+ });
289
+ if (tracked.part !== null)
290
+ await ctx.emit({ type: tracked.part.type, data: tracked.part });
291
+ if (state.writes.length > 0) {
292
+ const card = filesCard(description, state.writes, script);
293
+ if (card !== null)
294
+ await ctx.emit({ type: "data-file-reference", source: caller, data: card });
295
+ }
296
+ const run = {
297
+ analysisId: ctx.session.scope.analysisId,
298
+ threadId: ctx.session.scope.threadId,
299
+ invocationId: ctx.invocationId,
300
+ script,
301
+ reads: state.reads,
302
+ writes: state.writes,
303
+ tools: state.calls.map((call) => call.tool),
304
+ ok: result.ok,
305
+ };
306
+ const files = state.writes.length === 0 ? undefined : state.writes.map((write) => write.path);
307
+ const outcome = result.ok
308
+ ? {
309
+ ok: true,
310
+ output,
311
+ value: result.value === undefined ? undefined : plainJson(result.value),
312
+ calls: state.calls,
313
+ script: script?.path,
314
+ files,
315
+ }
316
+ : { ok: false, output, error: `${result.error.kind}: ${result.error.message}`, calls: state.calls, script: script?.path, files };
317
+ // Only a scratch directory folds the run into provenance, thus a script with none records no run.
318
+ const record = { run: scratch === undefined ? undefined : run, nested: state.nested };
319
+ return ok(record.run === undefined && record.nested.length === 0 ? outcome : withToolCallRecord(outcome, record));
320
+ }
321
+ finally {
322
+ await sandbox.close();
323
+ }
324
+ },
325
+ });
326
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The scratch directory of a session: the scripts that `run_script` ran, and the files that those scripts derived.
3
+ *
4
+ * A conversation keeps them at `conversation-sessions/{threadId}/`. `scripts/` holds each script under the id of
5
+ * its call, saved before the script runs, thus a failed script is on record too. `derived/` holds each file that a
6
+ * script wrote, and it is the one place where a script can write: an input and a run output stay out of its reach.
7
+ *
8
+ * A derived name holds one file for the life of the session, because a reply can cite the file as its evidence.
9
+ * A second write of the name with other bytes is refused. The same bytes again pass with no write, thus a step that
10
+ * runs its script again after a crash lands nothing new.
11
+ */
12
+ import type { AgentSession } from "../../auth/types.js";
13
+ import type { Logger } from "../../lib/logger.js";
14
+ import { type ProvenanceSeam } from "../../provenance/seam.js";
15
+ import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
16
+ /** The cap of one derived file. It matches the cap of a table read, thus a script can read back what it wrote. */
17
+ export declare const DERIVED_FILE_CAP_BYTES: number;
18
+ /** The cap of the derived files of one session together. */
19
+ export declare const DERIVED_SESSION_CAP_BYTES: number;
20
+ /** One file of the scratch directory: the analysis-relative path, and the hash and the size of its bytes. */
21
+ export type ScratchFile = {
22
+ readonly path: string;
23
+ readonly hash: string;
24
+ readonly size: number;
25
+ };
26
+ /** A file that a script read: the analysis-relative path and the hash of the bytes that it read. */
27
+ export type ScriptRead = {
28
+ readonly path: string;
29
+ readonly hash: string;
30
+ };
31
+ export type DerivedWrite = {
32
+ readonly status: "written";
33
+ readonly file: ScratchFile;
34
+ } | {
35
+ readonly status: "refused";
36
+ readonly reason: string;
37
+ };
38
+ /**
39
+ * What one script did to files, for provenance. The harness writes the script before it runs, and each script tool
40
+ * that reads or writes a file gives its part as the call record of the nested call.
41
+ */
42
+ export type ScriptRunRecord = {
43
+ readonly analysisId: string;
44
+ readonly threadId?: string;
45
+ /** The id of the `run_script` call. */
46
+ readonly invocationId: string;
47
+ /** The saved script. Absent when the session has no thread, thus no scratch directory. */
48
+ readonly script?: ScratchFile;
49
+ readonly reads: readonly ScriptRead[];
50
+ readonly writes: readonly ScratchFile[];
51
+ /** Each tool that the script called, in the order of the calls. */
52
+ readonly tools: readonly string[];
53
+ readonly ok: boolean;
54
+ };
55
+ export type ScriptScratch = {
56
+ /** Save the code of one script before it runs. Gives `undefined` when the scope of the session names no thread. */
57
+ saveScript(args: {
58
+ readonly session: AgentSession;
59
+ readonly invocationId: string;
60
+ readonly code: string;
61
+ }): Promise<ScratchFile | undefined>;
62
+ /** Write one derived file. A refusal is data that the script reads as a failed call. */
63
+ writeDerived(args: {
64
+ readonly session: AgentSession;
65
+ readonly invocationId: string;
66
+ readonly name: string;
67
+ /** Text lands as UTF-8. */
68
+ readonly content: string | Uint8Array;
69
+ }): Promise<DerivedWrite>;
70
+ /** Record what one script did to files. A script that read and wrote nothing records nothing. */
71
+ recordRun(record: ScriptRunRecord): void;
72
+ };
73
+ export type ScriptScratchDeps = {
74
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
75
+ /** The scratch directory of one thread, workspace-root-relative, for example `conversationSessionDir`. */
76
+ readonly sessionDir: (threadId: string) => string;
77
+ /** The provenance seam. An unbound session emit records nothing, and the scripts run the same. */
78
+ readonly provenance?: ProvenanceSeam;
79
+ readonly logger?: Logger;
80
+ };
81
+ export declare function createScriptScratch(deps: ScriptScratchDeps): ScriptScratch;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The scratch directory of a session: the scripts that `run_script` ran, and the files that those scripts derived.
3
+ *
4
+ * A conversation keeps them at `conversation-sessions/{threadId}/`. `scripts/` holds each script under the id of
5
+ * its call, saved before the script runs, thus a failed script is on record too. `derived/` holds each file that a
6
+ * script wrote, and it is the one place where a script can write: an input and a run output stay out of its reach.
7
+ *
8
+ * A derived name holds one file for the life of the session, because a reply can cite the file as its evidence.
9
+ * A second write of the name with other bytes is refused. The same bytes again pass with no write, thus a step that
10
+ * runs its script again after a crash lands nothing new.
11
+ */
12
+ import { lstat, readdir } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { createNoopLogger } from "../../lib/console-logger.js";
15
+ import { computeSha256, computeSha256File } from "../../lib/fs-helpers.js";
16
+ import { tryFs } from "../../lib/fs-result.js";
17
+ import { unwrapOrThrow } from "../../lib/result.js";
18
+ import { bindSessionEmit } from "../../provenance/seam.js";
19
+ import { isSafeId } from "../../workspace/paths.js";
20
+ import { confinedWrite } from "../workspace/mutator.js";
21
+ /** The cap of one derived file. It matches the cap of a table read, thus a script can read back what it wrote. */
22
+ export const DERIVED_FILE_CAP_BYTES = 16 * 1024 * 1024;
23
+ /** The cap of the derived files of one session together. */
24
+ export const DERIVED_SESSION_CAP_BYTES = 256 * 1024 * 1024;
25
+ /** The file name of a script. A call id that is not a safe file name is hashed, thus each id gives one name. */
26
+ function scriptFileName(invocationId) {
27
+ const stem = isSafeId(invocationId) ? invocationId : computeSha256(Buffer.from(invocationId, "utf8")).slice(0, 32);
28
+ return `${stem}.js`;
29
+ }
30
+ /** A derived name is one file name: no directory, no separator, no traversal, and no hidden file. */
31
+ function isDerivedName(name) {
32
+ return isSafeId(name) && !name.startsWith(".") && name.length <= 128;
33
+ }
34
+ async function existingAt(absolute) {
35
+ const stats = unwrapOrThrow(await tryFs("scratch.lstatDerived", () => lstat(absolute), { path: absolute, onAbsent: () => null }));
36
+ if (stats === null)
37
+ return { kind: "absent" };
38
+ if (!stats.isFile())
39
+ return { kind: "other" };
40
+ return { kind: "file", hash: await computeSha256File(absolute) };
41
+ }
42
+ /** The bytes of the regular files of one directory. A directory that is not there holds none. */
43
+ async function directoryBytes(absolute) {
44
+ const entries = unwrapOrThrow(await tryFs("scratch.readDerived", () => readdir(absolute, { withFileTypes: true }), { path: absolute, onAbsent: () => [] }));
45
+ let total = 0;
46
+ for (const entry of entries) {
47
+ if (!entry.isFile())
48
+ continue;
49
+ const path = join(absolute, entry.name);
50
+ const stats = unwrapOrThrow(await tryFs("scratch.lstatEntry", () => lstat(path), { path, onAbsent: () => null }));
51
+ total += stats?.size ?? 0;
52
+ }
53
+ return total;
54
+ }
55
+ export function createScriptScratch(deps) {
56
+ const logger = (deps.logger ?? createNoopLogger()).named("script-scratch");
57
+ const observe = bindSessionEmit(deps.provenance, logger);
58
+ async function land(session, dir, name, content, invocationId) {
59
+ const { analysisId } = session.scope;
60
+ const workspaceRoot = await deps.resolveWorkspaceRoot(analysisId);
61
+ const landed = await confinedWrite({
62
+ workspaceRoot,
63
+ analysisId,
64
+ workingDir: join(workspaceRoot, dir),
65
+ path: name,
66
+ content,
67
+ invocationId,
68
+ beforeLand: undefined,
69
+ refuseStagedInputs: true,
70
+ });
71
+ if (landed.status !== "ok")
72
+ return { refused: `the write of ${dir}/${name} was refused (${landed.status})` };
73
+ return { path: landed.relative, hash: computeSha256(landed.bytes), size: landed.bytes.length };
74
+ }
75
+ return {
76
+ async saveScript({ session, invocationId, code }) {
77
+ const { threadId } = session.scope;
78
+ if (threadId === undefined)
79
+ return undefined;
80
+ const saved = await land(session, `${deps.sessionDir(threadId)}/scripts`, scriptFileName(invocationId), code, invocationId);
81
+ if ("refused" in saved)
82
+ throw new Error(saved.refused);
83
+ return saved;
84
+ },
85
+ async writeDerived({ session, invocationId, name, content }) {
86
+ const { analysisId, threadId } = session.scope;
87
+ if (threadId === undefined)
88
+ return { status: "refused", reason: "this session has no thread, thus a script has no directory to write in" };
89
+ if (!isDerivedName(name)) {
90
+ return {
91
+ status: "refused",
92
+ reason: "a name is one file name of letters, digits, '.', '_', and '-', with no directory, for example de_top50.csv",
93
+ };
94
+ }
95
+ const data = typeof content === "string" ? Buffer.from(content, "utf8") : Buffer.from(content);
96
+ const bytes = data.length;
97
+ if (bytes > DERIVED_FILE_CAP_BYTES) {
98
+ return {
99
+ status: "refused",
100
+ reason: `the file is ${String(bytes)} bytes, and the cap of a derived file is ${String(DERIVED_FILE_CAP_BYTES)} bytes`,
101
+ };
102
+ }
103
+ const dir = `${deps.sessionDir(threadId)}/derived`;
104
+ const absoluteDir = join(await deps.resolveWorkspaceRoot(analysisId), dir);
105
+ const hash = computeSha256(data);
106
+ const existing = await existingAt(join(absoluteDir, name));
107
+ if (existing.kind === "other")
108
+ return { status: "refused", reason: `${dir}/${name} is not a regular file` };
109
+ if (existing.kind === "file") {
110
+ if (existing.hash === hash)
111
+ return { status: "written", file: { path: `${dir}/${name}`, hash, size: bytes } };
112
+ return { status: "refused", reason: `this session already holds a different ${name}, thus this file takes a new name` };
113
+ }
114
+ const used = await directoryBytes(absoluteDir);
115
+ if (used + bytes > DERIVED_SESSION_CAP_BYTES) {
116
+ return { status: "refused", reason: `the derived files of this session would pass the cap of ${String(DERIVED_SESSION_CAP_BYTES)} bytes` };
117
+ }
118
+ const landed = await land(session, dir, name, content, invocationId);
119
+ if ("refused" in landed)
120
+ return { status: "refused", reason: landed.refused };
121
+ return { status: "written", file: landed };
122
+ },
123
+ recordRun(record) {
124
+ if (record.reads.length === 0 && record.writes.length === 0)
125
+ return;
126
+ const event = {
127
+ type: "run-script",
128
+ analysisId: record.analysisId,
129
+ invocationId: record.invocationId,
130
+ reads: record.reads.map((read) => ({ path: read.path, hash: read.hash })),
131
+ writes: record.writes.map((write) => ({ path: write.path, hash: write.hash, size: write.size })),
132
+ tools: [...record.tools],
133
+ ok: record.ok,
134
+ };
135
+ if (record.threadId !== undefined)
136
+ event.threadId = record.threadId;
137
+ if (record.script !== undefined)
138
+ event.script = { path: record.script.path, hash: record.script.hash, size: record.script.size };
139
+ observe(event);
140
+ },
141
+ };
142
+ }
@@ -67,7 +67,7 @@ const startReportSessionInput = z.object({
67
67
  newSessionAnyway: z
68
68
  .boolean()
69
69
  .optional()
70
- .describe("Set it to true to start a new session when the conversation holds no user turn of new work after the last report session. The default is false."),
70
+ .describe("Set it to true to start a new session when the conversation holds no user turn of new work after the last report session, and only when the user asks for a separate report. The default is false."),
71
71
  });
72
72
  /** The line that a call reads when the scope names no conversation thread. */
73
73
  const NO_THREAD_SCOPE_DETAIL = "the scope of the call names no conversation thread, thus the tool cannot start a child session";
@@ -142,12 +142,13 @@ export function createEditFileTool(deps) {
142
142
  const stepKey = deps.stepKey ?? durableStepKey;
143
143
  return defineTool({
144
144
  id: "edit_file",
145
+ ownStep: true,
145
146
  description: "Edit a file in your working directory by replacing specific text. " +
146
147
  "Read the file first to get the exact text. When replace_all is false " +
147
148
  "(default), old_string must occur exactly once. For a bulk pattern " +
148
149
  "edit set regex=true: old_string is a JS regex, new_string may use $1 " +
149
150
  "references, and exactly one of replace_all=true or expected_matches " +
150
- "gates the write. Returns `out_of_prefix` / `out_of_scope` / `symlink_denied` / " +
151
+ "gates the write. Returns `out_of_prefix` / `out_of_scope` / `symlink_denied` / `read_only` / " +
151
152
  "`file_not_found` / `not_found` / `not_unique` / `match_count_mismatch` / " +
152
153
  "`invalid_pattern` / `invalid_arguments` data variants — never throws on expected outcomes. " +
153
154
  "A data variant other than `ok` writes nothing.",
@@ -2,12 +2,11 @@
2
2
  * `execute_command` — the single chokepoint through which sandbox commands
3
3
  * run.
4
4
  *
5
- * Dependency-bearing factory (see the harness-durable-runtime spec): captures a
6
- * `SandboxClient` and the live `SandboxRef` for the step. No other tool, agent,
7
- * or workflow step is permitted to POST to sandbox-server's `/exec` directly —
8
- * `SandboxClient` is intentionally injected only here so the durability /
9
- * idempotency / liveness story `harness-sandbox-exec` owns holds uniformly
10
- * across all sandbox command execution.
5
+ * Dependency-bearing factory (see the harness-durable-runtime spec): captures the
6
+ * lease of the step. The sandbox module gives the machine of the lease, or a new
7
+ * one when the machine went away, thus the durability, idempotency, and liveness
8
+ * story of `harness-sandbox-exec` holds for each command. A command with no lease
9
+ * gives an error result that names `acquire_sandbox`.
11
10
  *
12
11
  * The loop runs each call as one durable step, and the exec runs inside it,
13
12
  * thus the exec id is the id of that step and a replay returns the cached
@@ -27,8 +26,8 @@
27
26
  */
28
27
  import { z } from "zod";
29
28
  import { type Tool, type WithToolCallRecord } from "../define-tool.js";
30
- import type { SandboxClient } from "../../sandbox/client.js";
31
- import type { ProvenanceFrame, SandboxRef } from "../../sandbox/types.js";
29
+ import type { BoundLease } from "../../sandbox/leases.js";
30
+ import type { ProvenanceFrame } from "../../sandbox/types.js";
32
31
  import type { ProvenanceCollector } from "../../provenance/collector.js";
33
32
  import type { WorkspaceMutator, WriteRecord } from "./mutator.js";
34
33
  import { type BoundedExecResult } from "./result-bounds.js";
@@ -55,11 +54,8 @@ type ExecuteCommandResult = {
55
54
  export type ExecuteCommandDeps = {
56
55
  /** Operational logging seam; omitted falls back to no-op. */
57
56
  readonly logger?: Logger;
58
- readonly sandboxClient: SandboxClient;
59
- /** Live sandbox handle for the step. Created once per step at composition root. */
60
- readonly sandbox: SandboxRef;
61
- /** Absolute unix-ms deadline of the exec. Typically derived from `step.timeout`. */
62
- readonly deadlineMs: () => number;
57
+ /** The lease of the step: `acquire_sandbox` takes it, and each command runs on its machine. */
58
+ readonly lease: BoundLease;
63
59
  /**
64
60
  * In-sandbox absolute path of the agent's working directory (e.g.
65
61
  * `/{resourceId}/runs/{runId}/{stepId}`). Used as the default `cwd` so a