@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.
- package/README.md +1 -1
- package/dist/agents/conversation-agent.d.ts +6 -0
- package/dist/agents/conversation-agent.js +2 -0
- package/dist/agents/report-session-agent.d.ts +3 -0
- package/dist/agents/report-session-agent.js +13 -12
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/lib/hooks.d.ts +1 -1
- package/dist/loop/chat-stream.js +2 -1
- package/dist/loop/types.d.ts +5 -0
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +7 -5
- package/dist/runtime/assemble.d.ts +13 -4
- package/dist/runtime/assemble.js +2 -0
- package/dist/runtime/dbos.d.ts +6 -0
- package/dist/runtime/dbos.js +14 -2
- package/dist/state/artifacts.d.ts +4 -1
- package/dist/state/artifacts.js +4 -1
- package/dist/state/index.d.ts +4 -4
- package/dist/state/index.js +2 -2
- package/dist/state/plans.d.ts +27 -0
- package/dist/state/plans.js +57 -3
- package/dist/state/step-executions.d.ts +13 -0
- package/dist/state/step-executions.js +22 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +2 -2
- package/dist/tools/report-authoring/authoring-tools.js +31 -6
- package/dist/tools/report-session/script-tools.d.ts +3 -0
- package/dist/tools/report-session/script-tools.js +10 -1
- package/dist/tools/script/archive.d.ts +34 -0
- package/dist/tools/script/archive.js +182 -0
- package/dist/tools/script/file-tools.d.ts +10 -4
- package/dist/tools/script/file-tools.js +156 -42
- package/dist/tools/script/metrics.d.ts +10 -1
- package/dist/tools/script/metrics.js +17 -2
- package/dist/tools/script/run-script.js +91 -4
- package/dist/tools/script/scratch.d.ts +3 -0
- package/dist/tools/script/scratch.js +7 -4
- package/dist/tools/workspace/mutator.d.ts +3 -0
- package/dist/tools/workspace/mutator.js +11 -1
- package/dist/workspace/session-files.d.ts +35 -0
- package/dist/workspace/session-files.js +16 -0
- 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
|
|
56
|
-
*
|
|
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
|
-
|
|
59
|
-
const { path,
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return err(`${path}
|
|
64
|
-
|
|
65
|
-
const read = await args.workspaceFs.readFile({ session: args.session, path, maxBytes:
|
|
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
|
|
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
|
|
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(`${
|
|
128
|
+
return err(`${name} did not parse as ${format}: a ragged row, a repeated header, or a nested JSON value`);
|
|
81
129
|
}
|
|
82
|
-
recordReadBytes(
|
|
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({
|
|
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({
|
|
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 {
|
|
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(
|
|
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 }) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
173
|
-
const
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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", {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|