@inflexa-ai/harness 0.47.0 → 0.48.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 (42) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/conversation-agent.d.ts +6 -0
  3. package/dist/agents/conversation-agent.js +2 -0
  4. package/dist/agents/report-session-agent.d.ts +3 -0
  5. package/dist/agents/report-session-agent.js +13 -12
  6. package/dist/index.d.ts +5 -3
  7. package/dist/index.js +3 -2
  8. package/dist/lib/hooks.d.ts +1 -1
  9. package/dist/loop/chat-stream.js +2 -1
  10. package/dist/loop/types.d.ts +5 -0
  11. package/dist/prompts/report-session.d.ts +1 -1
  12. package/dist/prompts/report-session.js +7 -5
  13. package/dist/runtime/assemble.d.ts +13 -4
  14. package/dist/runtime/assemble.js +2 -0
  15. package/dist/runtime/dbos.d.ts +6 -0
  16. package/dist/runtime/dbos.js +14 -2
  17. package/dist/state/artifacts.d.ts +4 -1
  18. package/dist/state/artifacts.js +4 -1
  19. package/dist/state/index.d.ts +4 -4
  20. package/dist/state/index.js +2 -2
  21. package/dist/state/plans.d.ts +27 -0
  22. package/dist/state/plans.js +57 -3
  23. package/dist/state/step-executions.d.ts +13 -0
  24. package/dist/state/step-executions.js +22 -0
  25. package/dist/tools/report-authoring/authoring-tools.d.ts +2 -2
  26. package/dist/tools/report-authoring/authoring-tools.js +31 -6
  27. package/dist/tools/report-session/script-tools.d.ts +3 -0
  28. package/dist/tools/report-session/script-tools.js +10 -1
  29. package/dist/tools/script/archive.d.ts +34 -0
  30. package/dist/tools/script/archive.js +182 -0
  31. package/dist/tools/script/file-tools.d.ts +10 -4
  32. package/dist/tools/script/file-tools.js +156 -42
  33. package/dist/tools/script/metrics.d.ts +10 -1
  34. package/dist/tools/script/metrics.js +17 -2
  35. package/dist/tools/script/run-script.js +91 -4
  36. package/dist/tools/script/scratch.d.ts +3 -0
  37. package/dist/tools/script/scratch.js +7 -4
  38. package/dist/tools/workspace/mutator.d.ts +3 -0
  39. package/dist/tools/workspace/mutator.js +11 -1
  40. package/dist/workspace/session-files.d.ts +35 -0
  41. package/dist/workspace/session-files.js +16 -0
  42. package/package.json +2 -1
@@ -7,7 +7,8 @@
7
7
  * directory of the scratch of the session.
8
8
  *
9
9
  * A table read holds at most 16 MiB, and the parse runs on the host. A larger table, or a format with no parser
10
- * here, is work for an analysis run, and the refusal says so.
10
+ * here, is work for an analysis run, and the refusal says so. `list_archive` lists the files of a compressed file, and
11
+ * a read names one of them as its `entry`. A gzip-compressed table reads with no entry.
11
12
  *
12
13
  * Each tool gives the files that it read or wrote as its call record. `run_script` collects the records of the
13
14
  * calls of one script, thus the provenance of the script chains each derived file to the tables that it read.
@@ -24,6 +25,7 @@ import { parseUnknownRecord, UnknownArraySchema } from "../../lib/unknown-contai
24
25
  import { resolveWorkspacePath } from "../../workspace/paths.js";
25
26
  import { defineTool, withToolCallRecord } from "../define-tool.js";
26
27
  import { normalizeEchartSpec } from "../display/normalize-echart-spec.js";
28
+ import { ARCHIVE_SCAN_CAP_BYTES, archiveKindOf, gzipEntryName, listArchive, readArchiveEntry } from "./archive.js";
27
29
  import { recordReadBytes, recordReadRefusal } from "./metrics.js";
28
30
  /** The cap of one table read. It matches the cap of the report path, which reads with the same parsers. */
29
31
  export const TABLE_READ_CAP_BYTES = 16 * 1024 * 1024;
@@ -51,18 +53,25 @@ function numericCell(text) {
51
53
  return text;
52
54
  return value;
53
55
  }
56
+ /** The cap of the compressed file that a read of an entry reads. */
57
+ const ARCHIVE_READ_CAP_BYTES = 64 * 1024 * 1024;
58
+ const entryInput = z
59
+ .string()
60
+ .min(1)
61
+ .optional()
62
+ .describe("The file inside a compressed file at `path` (zip, tar, tar.gz), as `list_archive` names it. Absent: `path` itself.");
54
63
  /**
55
- * Read one table through the read seam, parse it on the host, and keep the named columns. A refusal is the line
56
- * that the script reads. `tooLarge` names where a table over the cap goes instead.
64
+ * Read one file of the workspace through the read seam, or one entry of a compressed file. A gzip-compressed file
65
+ * with no entry reads as its one entry. A refusal is the line that the script reads.
57
66
  */
58
- export async function readTableRows(args) {
59
- const { path, columns } = args;
60
- const format = detectFormat(path);
61
- if (format === undefined) {
62
- recordReadRefusal("unsupported_format");
63
- return err(`${path} has no table format that a script reads (csv, tsv, json, parquet); an analysis run reads it`);
64
- }
65
- const read = await args.workspaceFs.readFile({ session: args.session, path, maxBytes: TABLE_READ_CAP_BYTES });
67
+ async function readScriptFile(args) {
68
+ const { path, maxBytes } = args;
69
+ const kind = archiveKindOf(path);
70
+ const entry = args.entry ?? (kind === "gzip" ? gzipEntryName(path) : undefined);
71
+ if (entry !== undefined && kind === undefined)
72
+ return err(`${path} is not a compressed file that a script opens (zip, tar, tar.gz, gz)`);
73
+ const cap = kind === undefined ? maxBytes : ARCHIVE_READ_CAP_BYTES;
74
+ const read = await args.workspaceFs.readFile({ session: args.session, path, maxBytes: cap });
66
75
  if (read.isErr())
67
76
  return err(`${path} could not be read`);
68
77
  const file = read.value;
@@ -72,14 +81,53 @@ export async function readTableRows(args) {
72
81
  return err(`${path} is outside this analysis`);
73
82
  if (file.kind === "truncated") {
74
83
  recordReadRefusal("too_large");
75
- return err(`${path} is ${String(file.totalSize)} bytes, over the 16 MiB cap of a script read; ${args.tooLarge}`);
84
+ return err(`${path} is ${String(file.totalSize)} bytes, over the ${String(cap / (1024 * 1024))} MiB cap of a script read; ${args.tooLarge}`);
85
+ }
86
+ if (kind === undefined || entry === undefined)
87
+ return ok({ content: file.content, source: file.content });
88
+ const opened = readArchiveEntry(kind, path, file.content, entry, maxBytes);
89
+ if (opened.isOk())
90
+ return ok({ content: opened.value, source: file.content });
91
+ switch (opened.error.kind) {
92
+ case "not_found":
93
+ return err(`${path} holds no file ${entry}; list_archive gives its files`);
94
+ case "too_large":
95
+ recordReadRefusal("too_large");
96
+ return err(`${entry} in ${path} is over the ${String(maxBytes / (1024 * 1024))} MiB cap of a script read, or its archive decompresses past ${String(ARCHIVE_SCAN_CAP_BYTES / (1024 * 1024))} MiB; ${args.tooLarge}`);
97
+ case "unparsable":
98
+ recordReadRefusal("unparsable");
99
+ return err(`${path} did not open: ${opened.error.detail}`);
100
+ default: {
101
+ const unhandled = opened.error;
102
+ throw new Error(`unhandled entry refusal: ${JSON.stringify(unhandled)}`);
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * Read one table through the read seam, parse it on the host, and keep the named columns. A refusal is the line
108
+ * that the script reads. `tooLarge` names where a table over the cap goes instead.
109
+ */
110
+ export async function readTableRows(args) {
111
+ const { path, columns } = args;
112
+ const kind = archiveKindOf(path);
113
+ const name = args.entry ?? (kind === "gzip" ? gzipEntryName(path) : path);
114
+ const format = detectFormat(name);
115
+ if (format === undefined) {
116
+ recordReadRefusal("unsupported_format");
117
+ if (kind !== undefined && kind !== "gzip" && args.entry === undefined)
118
+ return err(`${path} is a compressed file; list_archive gives its files, and an entry names one`);
119
+ return err(`${name} has no table format that a script reads (csv, tsv, json, parquet); an analysis run reads it`);
76
120
  }
77
- const parsed = await parseTable(format, file.content);
121
+ const read = await readScriptFile({ ...args, entry: args.entry, maxBytes: TABLE_READ_CAP_BYTES });
122
+ if (read.isErr())
123
+ return err(read.error);
124
+ const { content, source } = read.value;
125
+ const parsed = await parseTable(format, content);
78
126
  if (parsed === undefined) {
79
127
  recordReadRefusal("unparsable");
80
- return err(`${path} did not parse as ${format}: a ragged row, a repeated header, or a nested JSON value`);
128
+ return err(`${name} did not parse as ${format}: a ragged row, a repeated header, or a nested JSON value`);
81
129
  }
82
- recordReadBytes(file.content.length);
130
+ recordReadBytes(content.length);
83
131
  const header = [...new Set(parsed.flatMap((row) => Object.keys(row)))];
84
132
  const missing = (columns ?? []).filter((column) => !header.includes(column));
85
133
  if (missing.length > 0)
@@ -94,10 +142,11 @@ export async function readTableRows(args) {
94
142
  }
95
143
  return out;
96
144
  });
97
- return ok({ content: file.content, columns: kept, rows });
145
+ return ok({ source, columns: kept, rows });
98
146
  }
99
147
  const readTableInput = z.object({
100
148
  path: z.string().min(1).describe("Analysis-rooted path of the table, for example 'runs/<runId>/<stepId>/output/de_results.csv'."),
149
+ entry: entryInput,
101
150
  columns: z.array(z.string()).min(1).optional().describe("Keep only these columns. Absent: each column."),
102
151
  });
103
152
  export const ReadTableOutputSchema = z.object({
@@ -112,29 +161,88 @@ function createReadTableTool(deps) {
112
161
  id: "read_table",
113
162
  description: "Read one table of the workspace as rows: CSV, TSV, JSON (an array of flat objects), or parquet, up to 16 MiB. " +
114
163
  "Each row is an object keyed by column, and a cell that holds a plain number reads as a number. " +
164
+ "A gzip-compressed table reads as it is, and `entry` reads one table inside a zip or a tar. " +
115
165
  "A larger table, or another format such as h5ad, is work for an analysis run.",
116
166
  inputSchema: readTableInput,
117
167
  outputSchema: ReadTableOutputSchema,
118
- describeCall: ({ path }) => path,
168
+ describeCall: ({ path, entry }) => (entry === undefined ? path : `${path} : ${entry}`),
119
169
  describeResult: (_input, result) => `${String(result.rowCount)} rows`,
120
- execute: async ({ path, columns }, ctx) => {
121
- const table = await readTableRows({ workspaceFs: deps.workspaceFs, session: ctx.session, path, columns, tooLarge: "an analysis run reads it" });
170
+ execute: async ({ path, entry, columns }, ctx) => {
171
+ const table = await readTableRows({
172
+ workspaceFs: deps.workspaceFs,
173
+ session: ctx.session,
174
+ path,
175
+ entry,
176
+ columns,
177
+ tooLarge: "an analysis run reads it",
178
+ });
122
179
  if (table.isErr())
123
180
  return refused(table.error);
124
- const { content, rows } = table.value;
181
+ const { source, rows } = table.value;
125
182
  const { analysisId } = ctx.session.scope;
126
183
  const resolved = resolveWorkspacePath({ workspaceRoot: await deps.resolveWorkspaceRoot(analysisId), analysisId, path });
127
184
  const relative = resolved.kind === "ok" ? resolved.relative.split("\\").join("/") : path;
128
- const effect = { reads: [{ path: relative, hash: computeSha256(content) }] };
185
+ const effect = { reads: [{ path: relative, hash: computeSha256(source) }] };
129
186
  return ok(withToolCallRecord({ path: relative, columns: table.value.columns, rowCount: rows.length, rows }, effect));
130
187
  },
131
188
  });
132
189
  }
190
+ const listArchiveInput = z.object({
191
+ path: z.string().min(1).describe("Analysis-rooted path of a compressed file: .zip, .tar, .tar.gz, .tgz, or .gz."),
192
+ });
193
+ const ListArchiveOutputSchema = z.object({
194
+ path: z.string(),
195
+ entries: z.array(z.object({ name: z.string(), bytes: z.number() })).readonly(),
196
+ });
197
+ function createListArchiveTool(deps) {
198
+ return defineTool({
199
+ id: "list_archive",
200
+ description: "List the files inside a compressed file of the workspace (zip, tar, tar.gz, gz), each with its decompressed size. " +
201
+ "`read_table` and `read_pdf` then read one of them as their `entry`.",
202
+ inputSchema: listArchiveInput,
203
+ outputSchema: ListArchiveOutputSchema,
204
+ describeCall: ({ path }) => path,
205
+ describeResult: (_input, result) => `${String(result.entries.length)} files`,
206
+ execute: async ({ path }, ctx) => {
207
+ const kind = archiveKindOf(path);
208
+ if (kind === undefined) {
209
+ recordReadRefusal("unsupported_format");
210
+ return refused(`${path} is not a compressed file that a script opens (zip, tar, tar.gz, gz)`);
211
+ }
212
+ const read = await readScriptFile({
213
+ workspaceFs: deps.workspaceFs,
214
+ session: ctx.session,
215
+ path,
216
+ entry: undefined,
217
+ maxBytes: ARCHIVE_READ_CAP_BYTES,
218
+ tooLarge: "an analysis run opens it",
219
+ });
220
+ if (read.isErr())
221
+ return refused(read.error);
222
+ const { source } = read.value;
223
+ const listed = listArchive(kind, path, source);
224
+ if (listed.isErr()) {
225
+ if (listed.error.kind === "too_large") {
226
+ recordReadRefusal("too_large");
227
+ return refused(`${path} decompresses past ${String(ARCHIVE_SCAN_CAP_BYTES / (1024 * 1024))} MiB; an analysis run opens it`);
228
+ }
229
+ recordReadRefusal("unparsable");
230
+ return refused(`${path} did not open${listed.error.kind === "unparsable" ? `: ${listed.error.detail}` : ""}`);
231
+ }
232
+ const { analysisId } = ctx.session.scope;
233
+ const resolved = resolveWorkspacePath({ workspaceRoot: await deps.resolveWorkspaceRoot(analysisId), analysisId, path });
234
+ const relative = resolved.kind === "ok" ? resolved.relative.split("\\").join("/") : path;
235
+ const effect = { reads: [{ path: relative, hash: computeSha256(source) }] };
236
+ return ok(withToolCallRecord({ path: relative, entries: listed.value }, effect));
237
+ },
238
+ });
239
+ }
133
240
  /** The cap of one PDF read, and the pages that one call reads. */
134
241
  const PDF_READ_CAP_BYTES = 64 * 1024 * 1024;
135
242
  const PDF_PAGES_PER_READ = 50;
136
243
  const readPdfInput = z.object({
137
244
  path: z.string().min(1).describe("Analysis-rooted path of the PDF, for example 'data/inputs/<fileId>/paper.pdf'."),
245
+ entry: entryInput,
138
246
  firstPage: z.number().int().min(1).optional().describe("The first page to read, 1-based. Absent: 1."),
139
247
  lastPage: z
140
248
  .number()
@@ -158,42 +266,48 @@ export function createReadPdfTool(deps) {
158
266
  "A scanned page with no text layer reads as an empty text.",
159
267
  inputSchema: readPdfInput,
160
268
  outputSchema: ReadPdfOutputSchema,
161
- describeCall: ({ path, firstPage, lastPage }) => firstPage === undefined && lastPage === undefined ? path : `${path}, pages ${String(firstPage ?? 1)}-${String(lastPage ?? "")}`,
269
+ describeCall: ({ path, entry, firstPage, lastPage }) => {
270
+ const file = entry === undefined ? path : `${path} : ${entry}`;
271
+ return firstPage === undefined && lastPage === undefined ? file : `${file}, pages ${String(firstPage ?? 1)}-${String(lastPage ?? "")}`;
272
+ },
162
273
  describeResult: (_input, result) => `${String(result.pages.length)} of ${String(result.totalPages)} pages`,
163
274
  // `run_script` folds the record of a nested call only into a tool that folds records.
164
275
  foldCallRecord: lineage === undefined ? undefined : (effect) => lineage.track(effect.reads ?? []),
165
- execute: async ({ path, firstPage, lastPage }, ctx) => {
166
- if (!path.toLowerCase().endsWith(".pdf")) {
276
+ execute: async ({ path, entry, firstPage, lastPage }, ctx) => {
277
+ const kind = archiveKindOf(path);
278
+ const name = entry ?? (kind === "gzip" ? gzipEntryName(path) : path);
279
+ if (!name.toLowerCase().endsWith(".pdf")) {
167
280
  recordReadRefusal("unsupported_format");
168
- return refused(`${path} is not a PDF; read_table reads a table, and read_file reads text`);
281
+ if (kind !== undefined && kind !== "gzip" && entry === undefined)
282
+ return refused(`${path} is a compressed file; list_archive gives its files, and an entry names one`);
283
+ return refused(`${name} is not a PDF; read_table reads a table, and read_file reads text`);
169
284
  }
170
- const read = await deps.workspaceFs.readFile({ session: ctx.session, path, maxBytes: PDF_READ_CAP_BYTES });
285
+ const read = await readScriptFile({
286
+ workspaceFs: deps.workspaceFs,
287
+ session: ctx.session,
288
+ path,
289
+ entry,
290
+ maxBytes: PDF_READ_CAP_BYTES,
291
+ tooLarge: "an analysis run reads it",
292
+ });
171
293
  if (read.isErr())
172
- return refused(`${path} could not be read`);
173
- const file = read.value;
174
- if (file.kind === "not_found")
175
- return refused(`${path} does not exist; find the path with workspace_search or list_files`);
176
- if (file.kind === "out_of_scope")
177
- return refused(`${path} is outside this analysis`);
178
- if (file.kind === "truncated") {
179
- recordReadRefusal("too_large");
180
- return refused(`${path} is ${String(file.totalSize)} bytes, over the 64 MiB cap of a PDF read`);
181
- }
294
+ return refused(read.error);
295
+ const { content, source } = read.value;
182
296
  const first = firstPage ?? 1;
183
297
  const last = Math.min(lastPage ?? first + PDF_PAGES_PER_READ - 1, first + PDF_PAGES_PER_READ - 1);
184
298
  let text;
185
299
  try {
186
- text = await readPdfText(file.content, { first, last });
300
+ text = await readPdfText(content, { first, last });
187
301
  }
188
302
  catch {
189
303
  recordReadRefusal("unparsable");
190
304
  return refused(`${path} did not open as a PDF: a damaged file, or a file with a password`);
191
305
  }
192
- recordReadBytes(file.content.length);
306
+ recordReadBytes(content.length);
193
307
  const { analysisId } = ctx.session.scope;
194
308
  const resolved = resolveWorkspacePath({ workspaceRoot: await deps.resolveWorkspaceRoot(analysisId), analysisId, path });
195
309
  const relative = resolved.kind === "ok" ? resolved.relative.split("\\").join("/") : path;
196
- const effect = { reads: [{ path: relative, hash: computeSha256(file.content) }] };
310
+ const effect = { reads: [{ path: relative, hash: computeSha256(source) }] };
197
311
  return ok(withToolCallRecord({ path: relative, totalPages: text.totalPages, pages: text.pages }, effect));
198
312
  },
199
313
  });
@@ -365,11 +479,11 @@ function createExportPdfTool(deps) {
365
479
  });
366
480
  }
367
481
  export function createScriptFileTools(deps) {
368
- return [createReadTableTool(deps), createReadPdfTool(deps), createWriteDerivedTool(deps), ...createDocumentTools(deps)];
482
+ return [createReadTableTool(deps), createReadPdfTool(deps), createListArchiveTool(deps), createWriteDerivedTool(deps), ...createDocumentTools(deps)];
369
483
  }
370
- /** The script tools that read a table and a PDF of the workspace, and write nothing. */
484
+ /** The script tools that read a table, a PDF, and the files of a compressed file of the workspace, and write nothing. */
371
485
  export function createReadScriptTools(deps) {
372
- return [createReadTableTool(deps), createReadPdfTool(deps)];
486
+ return [createReadTableTool(deps), createReadPdfTool(deps), createListArchiveTool(deps)];
373
487
  }
374
488
  /** The tools that make a document in a script: a chart as an SVG file, and a PDF from markdown. */
375
489
  export function createDocumentTools(deps) {
@@ -9,13 +9,22 @@
9
9
  * - cortex.harness.script.read_refusals — counter, one for each table read that a script tool refused, with
10
10
  * `reason` (`too_large`, `unsupported_format`, or `unparsable`). A rise of `too_large` and `unsupported_format`
11
11
  * says that the work wants the tables that only a sandbox reads.
12
- * - cortex.harness.script.read_bytes — histogram of the bytes of each table that a script read.
12
+ * - cortex.harness.script.read_bytes — histogram of the bytes of each table or PDF that a script read, in buckets
13
+ * from 1 KiB up to the PDF read cap.
13
14
  *
14
15
  * A recovery replays the cached result of a script and runs no body, thus a replay never counts again. The
15
16
  * instruments are made on the first use, thus they bind to whatever provider the host registers.
16
17
  */
17
18
  export type ScriptFailure = "none" | "script" | "memory" | "timeout" | "aborted" | "sandbox";
18
19
  export type ReadRefusal = "too_large" | "unsupported_format" | "unparsable";
20
+ /**
21
+ * Bucket bounds for the bytes of one read: 1 KiB to 64 MiB, a factor of 4 apart. The top bound is the PDF read cap,
22
+ * thus the overflow bucket stays empty. Without them the default bounds stop at 10000, and each larger read lands in
23
+ * the overflow bucket.
24
+ */
25
+ export declare const SCRIPT_READ_BOUNDS_BYTES: readonly number[];
19
26
  export declare function recordScriptRun(agentId: string, failure: ScriptFailure): void;
20
27
  export declare function recordReadRefusal(reason: ReadRefusal): void;
21
28
  export declare function recordReadBytes(bytes: number): void;
29
+ /** Drop the instruments, thus the next record binds to the provider that a test registered. */
30
+ export declare function __resetScriptMetricsForTest(): void;
@@ -9,12 +9,19 @@
9
9
  * - cortex.harness.script.read_refusals — counter, one for each table read that a script tool refused, with
10
10
  * `reason` (`too_large`, `unsupported_format`, or `unparsable`). A rise of `too_large` and `unsupported_format`
11
11
  * says that the work wants the tables that only a sandbox reads.
12
- * - cortex.harness.script.read_bytes — histogram of the bytes of each table that a script read.
12
+ * - cortex.harness.script.read_bytes — histogram of the bytes of each table or PDF that a script read, in buckets
13
+ * from 1 KiB up to the PDF read cap.
13
14
  *
14
15
  * A recovery replays the cached result of a script and runs no body, thus a replay never counts again. The
15
16
  * instruments are made on the first use, thus they bind to whatever provider the host registers.
16
17
  */
17
18
  import { metrics } from "@opentelemetry/api";
19
+ /**
20
+ * Bucket bounds for the bytes of one read: 1 KiB to 64 MiB, a factor of 4 apart. The top bound is the PDF read cap,
21
+ * thus the overflow bucket stays empty. Without them the default bounds stop at 10000, and each larger read lands in
22
+ * the overflow bucket.
23
+ */
24
+ export const SCRIPT_READ_BOUNDS_BYTES = [1_024, 4_096, 16_384, 65_536, 262_144, 1_048_576, 4_194_304, 16_777_216, 67_108_864];
18
25
  let instruments;
19
26
  function getInstruments() {
20
27
  if (instruments === undefined) {
@@ -22,7 +29,11 @@ function getInstruments() {
22
29
  instruments = {
23
30
  runs: meter.createCounter("cortex.harness.script.runs", { description: "Scripts that run_script ran" }),
24
31
  readRefusals: meter.createCounter("cortex.harness.script.read_refusals", { description: "Table reads of a script that a script tool refused" }),
25
- readBytes: meter.createHistogram("cortex.harness.script.read_bytes", { description: "Bytes of each table that a script read", unit: "By" }),
32
+ readBytes: meter.createHistogram("cortex.harness.script.read_bytes", {
33
+ description: "Bytes of each table or PDF that a script read",
34
+ unit: "By",
35
+ advice: { explicitBucketBoundaries: [...SCRIPT_READ_BOUNDS_BYTES] },
36
+ }),
26
37
  };
27
38
  }
28
39
  return instruments;
@@ -36,3 +47,7 @@ export function recordReadRefusal(reason) {
36
47
  export function recordReadBytes(bytes) {
37
48
  getInstruments().readBytes.record(bytes);
38
49
  }
50
+ /** Drop the instruments, thus the next record binds to the provider that a test registered. */
51
+ export function __resetScriptMetricsForTest() {
52
+ instruments = undefined;
53
+ }
@@ -33,6 +33,8 @@ const SCRIPT_TIMEOUT_MS = 180_000;
33
33
  const SCRIPT_MEMORY_BYTES = 256 * 1024 * 1024;
34
34
  /** The text of a result that reaches the model. A longer output keeps its head and its tail. */
35
35
  const MAX_RESULT_CHARS = 24_000;
36
+ /** The least time between two live copies of the nested calls of a script. */
37
+ const LIVE_ACTIVITY_INTERVAL_MS = 500;
36
38
  const inputSchema = z.object({
37
39
  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
40
  code: z
@@ -57,11 +59,59 @@ function plainJson(value) {
57
59
  return value === undefined ? null : parseJsonAs(JSON.stringify(value), z.json());
58
60
  }
59
61
  function describeTool(tool) {
60
- return renderToolSample({ name: tool.id, description: tool.description, inputSchema: tool.jsonSchema, outputSchema: tool.outputJsonSchema });
62
+ return renderToolSample({ name: tool.id, description: tool.description, inputSchema: inputSchemaOf(tool), outputSchema: tool.outputJsonSchema });
61
63
  }
62
64
  function issuesOf(error) {
63
65
  return error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
64
66
  }
67
+ /**
68
+ * The live copies of the activity part of one script, so a reader sees each nested call while the script runs. A copy
69
+ * is transient, and the part that the call emits at its end is the one that a stored display keeps. Each copy holds
70
+ * each entry so far, thus at most one copy goes in each interval. A copy that the interval holds back goes at the end
71
+ * of the interval, thus a long nested call shows before it ends.
72
+ */
73
+ function createLiveActivity(emit, log, intervalMs) {
74
+ let latest = null;
75
+ let lastSentMs = Number.NEGATIVE_INFINITY;
76
+ let timer;
77
+ let closed = false;
78
+ const inFlight = new Set();
79
+ const send = () => {
80
+ timer = undefined;
81
+ if (closed || latest === null)
82
+ return;
83
+ lastSentMs = Date.now();
84
+ const part = latest;
85
+ const sent = (async () => {
86
+ try {
87
+ await emit({ type: part.type, data: part, transient: true });
88
+ }
89
+ catch (err) {
90
+ log.warn("a live copy of the nested calls did not reach the stream", log.errorFields(err));
91
+ }
92
+ })();
93
+ inFlight.add(sent);
94
+ void sent.finally(() => inFlight.delete(sent));
95
+ };
96
+ return {
97
+ update(part) {
98
+ latest = part;
99
+ if (closed || timer !== undefined)
100
+ return;
101
+ const wait = lastSentMs + intervalMs - Date.now();
102
+ if (wait <= 0)
103
+ send();
104
+ // oxlint-disable-next-line eslint-js/no-restricted-syntax -- `close` clears it, and the call runs `close` in its `finally`, thus it ends with the call.
105
+ else
106
+ timer = setTimeout(send, wait);
107
+ },
108
+ async close() {
109
+ closed = true;
110
+ clearTimeout(timer);
111
+ await Promise.all(inFlight);
112
+ },
113
+ };
114
+ }
65
115
  /** Keep the files of the call record of one nested call. A record of another shape carries no file. */
66
116
  function collectEffect(state, value) {
67
117
  const effect = ScriptFileEffectSchema.safeParse(readToolCallRecord(value));
@@ -82,7 +132,7 @@ function scriptToolOf(tool, ctx, state, log, inCallContext) {
82
132
  return {
83
133
  name: tool.id,
84
134
  description: tool.description,
85
- inputSchema: tool.jsonSchema,
135
+ inputSchema: inputSchemaOf(tool),
86
136
  outputSchema: tool.outputJsonSchema,
87
137
  execute: (args, options) => inCallContext(() => runNestedCall(tool, ctx, state, log, args, options.signal)),
88
138
  };
@@ -148,6 +198,36 @@ function withoutProse(schema) {
148
198
  .filter(([key]) => key !== "description" && key !== "examples")
149
199
  .map(([key, value]) => [key, withoutProse(value)]));
150
200
  }
201
+ /**
202
+ * A JSON schema as a script reads it. A tool can union its real shape with the empty schema to keep its runtime parse
203
+ * permissive (`add_block` does, thus a malformed block reaches its typed refusal). The type of such a union is
204
+ * `unknown`, which hides the real shape, thus the empty member goes. The VM only renders the schema and validates
205
+ * nothing, and the tool still parses each input with its own schema.
206
+ */
207
+ function printableSchema(schema) {
208
+ const array = UnknownArraySchema.safeParse(schema);
209
+ if (array.success)
210
+ return array.data.map(printableSchema);
211
+ const record = parseUnknownRecord(schema);
212
+ if (record === undefined)
213
+ return schema;
214
+ let out = Object.fromEntries(Object.entries(record).map(([key, value]) => [key, printableSchema(value)]));
215
+ for (const key of ["anyOf", "oneOf"]) {
216
+ const members = UnknownArraySchema.safeParse(out[key]);
217
+ if (!members.success)
218
+ continue;
219
+ const shaped = members.data.filter((member) => Object.keys(parseUnknownRecord(member) ?? {}).length > 0);
220
+ if (shaped.length === 0 || shaped.length === members.data.length)
221
+ continue;
222
+ const rest = Object.fromEntries(Object.entries(out).filter(([name]) => name !== key));
223
+ out = shaped.length > 1 ? { ...rest, [key]: shaped } : { ...rest, ...parseUnknownRecord(shaped[0]) };
224
+ }
225
+ return out;
226
+ }
227
+ /** The input schema of a tool as a script reads it. See {@link printableSchema}. */
228
+ function inputSchemaOf(tool) {
229
+ return parseUnknownRecord(printableSchema(tool.jsonSchema)) ?? tool.jsonSchema;
230
+ }
151
231
  /** The longest result type that the catalog shows. A longer type shows as `unknown`, and `describe()` gives it whole. */
152
232
  const MAX_CATALOG_RESULT_TYPE_CHARS = 800;
153
233
  /** One catalog entry: the first sentence of the description, then the signature with no prose. */
@@ -155,7 +235,7 @@ function catalogEntry(tool) {
155
235
  const output = tool.outputJsonSchema === undefined ? undefined : parseUnknownRecord(withoutProse(tool.outputJsonSchema));
156
236
  const resultType = output === undefined ? "unknown" : schemaToType(output, { maxChars: MAX_CATALOG_RESULT_TYPE_CHARS });
157
237
  // 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)) ?? {} });
238
+ const untyped = renderToolSignature({ name: tool.id, inputSchema: parseUnknownRecord(withoutProse(inputSchemaOf(tool))) ?? {} });
159
239
  const signature = `${untyped.slice(0, -"unknown>;".length)}${resultType}>;`;
160
240
  return `/** ${firstSentence(tool.description)} */\n${signature}`;
161
241
  }
@@ -234,6 +314,7 @@ export function createRunScriptTool(scriptTools, runtime = {}, options = {}) {
234
314
  source: { agentId: caller.agentId, callPath: [...caller.callPath] },
235
315
  }, Date.now());
236
316
  const tracked = { part: null };
317
+ const live = createLiveActivity(ctx.emit, log, LIVE_ACTIVITY_INTERVAL_MS);
237
318
  let started = 0;
238
319
  const state = {
239
320
  code,
@@ -244,7 +325,11 @@ export function createRunScriptTool(scriptTools, runtime = {}, options = {}) {
244
325
  nextInvocationId: () => `${ctx.invocationId}:${++started}`,
245
326
  source: { agentId: RUN_SCRIPT_TOOL_ID, callPath: [...caller.callPath, RUN_SCRIPT_TOOL_ID], invocationId: ctx.invocationId },
246
327
  observe: (frame) => {
247
- tracked.part = tracker.track(frame, Date.now()) ?? tracked.part;
328
+ const part = tracker.track(frame, Date.now());
329
+ if (part === null)
330
+ return;
331
+ tracked.part = part;
332
+ live.update(part);
248
333
  },
249
334
  };
250
335
  // The VM calls a tool from a message event of its worker, and such an event runs outside the async context
@@ -286,6 +371,7 @@ export function createRunScriptTool(scriptTools, runtime = {}, options = {}) {
286
371
  reads: state.reads.length,
287
372
  writes: state.writes.length,
288
373
  });
374
+ await live.close();
289
375
  if (tracked.part !== null)
290
376
  await ctx.emit({ type: tracked.part.type, data: tracked.part });
291
377
  if (state.writes.length > 0) {
@@ -319,6 +405,7 @@ export function createRunScriptTool(scriptTools, runtime = {}, options = {}) {
319
405
  return ok(record.run === undefined && record.nested.length === 0 ? outcome : withToolCallRecord(outcome, record));
320
406
  }
321
407
  finally {
408
+ await live.close();
322
409
  await sandbox.close();
323
410
  }
324
411
  },
@@ -13,6 +13,7 @@ import type { AgentSession } from "../../auth/types.js";
13
13
  import type { Logger } from "../../lib/logger.js";
14
14
  import { type ProvenanceSeam } from "../../provenance/seam.js";
15
15
  import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
16
+ import { type SyncSessionFile } from "../../workspace/session-files.js";
16
17
  /** The cap of one derived file. It matches the cap of a table read, thus a script can read back what it wrote. */
17
18
  export declare const DERIVED_FILE_CAP_BYTES: number;
18
19
  /** The cap of the derived files of one session together. */
@@ -76,6 +77,8 @@ export type ScriptScratchDeps = {
76
77
  readonly sessionDir: (threadId: string) => string;
77
78
  /** The provenance seam. An unbound session emit records nothing, and the scripts run the same. */
78
79
  readonly provenance?: ProvenanceSeam;
80
+ /** The host copy of each file that lands, owned by the thread of the session. */
81
+ readonly syncSessionFile?: SyncSessionFile;
79
82
  readonly logger?: Logger;
80
83
  };
81
84
  export declare function createScriptScratch(deps: ScriptScratchDeps): ScriptScratch;
@@ -17,6 +17,7 @@ import { tryFs } from "../../lib/fs-result.js";
17
17
  import { unwrapOrThrow } from "../../lib/result.js";
18
18
  import { bindSessionEmit } from "../../provenance/seam.js";
19
19
  import { isSafeId } from "../../workspace/paths.js";
20
+ import { syncWrittenFile } from "../../workspace/session-files.js";
20
21
  import { confinedWrite } from "../workspace/mutator.js";
21
22
  /** The cap of one derived file. It matches the cap of a table read, thus a script can read back what it wrote. */
22
23
  export const DERIVED_FILE_CAP_BYTES = 16 * 1024 * 1024;
@@ -55,7 +56,7 @@ async function directoryBytes(absolute) {
55
56
  export function createScriptScratch(deps) {
56
57
  const logger = (deps.logger ?? createNoopLogger()).named("script-scratch");
57
58
  const observe = bindSessionEmit(deps.provenance, logger);
58
- async function land(session, dir, name, content, invocationId) {
59
+ async function land(session, threadId, dir, name, content, invocationId) {
59
60
  const { analysisId } = session.scope;
60
61
  const workspaceRoot = await deps.resolveWorkspaceRoot(analysisId);
61
62
  const landed = await confinedWrite({
@@ -70,14 +71,16 @@ export function createScriptScratch(deps) {
70
71
  });
71
72
  if (landed.status !== "ok")
72
73
  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
+ const file = { path: landed.relative, hash: computeSha256(landed.bytes), size: landed.bytes.length };
75
+ await syncWrittenFile(deps.syncSessionFile, logger, { session, owner: { kind: "thread", threadId }, ...file, content: landed.bytes });
76
+ return file;
74
77
  }
75
78
  return {
76
79
  async saveScript({ session, invocationId, code }) {
77
80
  const { threadId } = session.scope;
78
81
  if (threadId === undefined)
79
82
  return undefined;
80
- const saved = await land(session, `${deps.sessionDir(threadId)}/scripts`, scriptFileName(invocationId), code, invocationId);
83
+ const saved = await land(session, threadId, `${deps.sessionDir(threadId)}/scripts`, scriptFileName(invocationId), code, invocationId);
81
84
  if ("refused" in saved)
82
85
  throw new Error(saved.refused);
83
86
  return saved;
@@ -115,7 +118,7 @@ export function createScriptScratch(deps) {
115
118
  if (used + bytes > DERIVED_SESSION_CAP_BYTES) {
116
119
  return { status: "refused", reason: `the derived files of this session would pass the cap of ${String(DERIVED_SESSION_CAP_BYTES)} bytes` };
117
120
  }
118
- const landed = await land(session, dir, name, content, invocationId);
121
+ const landed = await land(session, threadId, dir, name, content, invocationId);
119
122
  if ("refused" in landed)
120
123
  return { status: "refused", reason: landed.refused };
121
124
  return { status: "written", file: landed };
@@ -38,6 +38,7 @@ import type { AgentSession } from "../../auth/types.js";
38
38
  import type { Logger } from "../../lib/logger.js";
39
39
  import type { ProvenanceCollector } from "../../provenance/collector.js";
40
40
  import { type ProvenanceSeam } from "../../provenance/seam.js";
41
+ import { type SyncSessionFile } from "../../workspace/session-files.js";
41
42
  import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
42
43
  /** Outcome of a confined write. Expected outcomes are data variants — never throws on them. */
43
44
  export type WriteFileResult = {
@@ -191,6 +192,8 @@ export type SessionWorkspaceMutatorDeps = {
191
192
  * proceeds unchanged.
192
193
  */
193
194
  readonly provenance?: ProvenanceSeam;
195
+ /** The host copy of each file that lands, owned by the analysis: the path is anywhere in its tree. */
196
+ readonly syncSessionFile?: SyncSessionFile;
194
197
  readonly logger?: Logger;
195
198
  };
196
199
  /**
@@ -43,6 +43,7 @@ import { computeSha256 } from "../../lib/fs-helpers.js";
43
43
  import { tryFs, tryFsWrite } from "../../lib/fs-result.js";
44
44
  import { unwrapOrThrow } from "../../lib/result.js";
45
45
  import { bindSessionEmit } from "../../provenance/seam.js";
46
+ import { syncWrittenFile } from "../../workspace/session-files.js";
46
47
  import { resolveForWrite, STAGED_INPUT_ROOT } from "../../workspace/paths.js";
47
48
  /**
48
49
  * Agent-visible name of the tool driving a confined write. Rides the write args
@@ -305,7 +306,16 @@ export function createSessionWorkspaceMutator(deps) {
305
306
  });
306
307
  if (landed.status !== "ok")
307
308
  return { ...landed, path };
308
- return landedWrite(landed);
309
+ const write = landedWrite(landed);
310
+ await syncWrittenFile(deps.syncSessionFile, logger, {
311
+ session,
312
+ owner: { kind: "analysis" },
313
+ path: write.record.path,
314
+ hash: write.record.hash,
315
+ size: write.record.size,
316
+ content: landed.bytes,
317
+ });
318
+ return write;
309
319
  },
310
320
  recordWrite(record) {
311
321
  const event = {