@inflexa-ai/harness 0.47.0 → 0.48.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 (36) 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 +3 -1
  7. package/dist/index.js +2 -1
  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/state/index.d.ts +2 -2
  16. package/dist/state/index.js +1 -1
  17. package/dist/state/step-executions.d.ts +13 -0
  18. package/dist/state/step-executions.js +22 -0
  19. package/dist/tools/report-authoring/authoring-tools.d.ts +2 -2
  20. package/dist/tools/report-authoring/authoring-tools.js +31 -6
  21. package/dist/tools/report-session/script-tools.d.ts +3 -0
  22. package/dist/tools/report-session/script-tools.js +10 -1
  23. package/dist/tools/script/archive.d.ts +34 -0
  24. package/dist/tools/script/archive.js +182 -0
  25. package/dist/tools/script/file-tools.d.ts +10 -4
  26. package/dist/tools/script/file-tools.js +156 -42
  27. package/dist/tools/script/metrics.d.ts +10 -1
  28. package/dist/tools/script/metrics.js +17 -2
  29. package/dist/tools/script/run-script.js +91 -4
  30. package/dist/tools/script/scratch.d.ts +3 -0
  31. package/dist/tools/script/scratch.js +7 -4
  32. package/dist/tools/workspace/mutator.d.ts +3 -0
  33. package/dist/tools/workspace/mutator.js +11 -1
  34. package/dist/workspace/session-files.d.ts +35 -0
  35. package/dist/workspace/session-files.js +16 -0
  36. package/package.json +2 -1
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The entries of a compressed file, for the read tools of a script: a zip, a tar, a gzip-compressed tar, or one
3
+ * gzip-compressed file.
4
+ *
5
+ * Each read runs on the host and holds the bytes in memory, thus each one has a cap. A zip names the size of each
6
+ * entry in its index, thus a list reads no entry and a read decompresses one entry. A tar has no index, thus a list
7
+ * and a read of a `.tar.gz` decompress the whole stream, up to {@link ARCHIVE_SCAN_CAP_BYTES}.
8
+ */
9
+ import { type Result } from "neverthrow";
10
+ export type ArchiveKind = "zip" | "tar" | "tar-gzip" | "gzip";
11
+ /** The cap of the decompressed stream of a gzip-compressed tar or file that a list or a read decompresses. */
12
+ export declare const ARCHIVE_SCAN_CAP_BYTES: number;
13
+ export type ArchiveEntry = {
14
+ readonly name: string;
15
+ readonly bytes: number;
16
+ };
17
+ /** Why an entry did not read. `too_large` carries the size of the entry, when the archive names it. */
18
+ export type EntryRefusal = {
19
+ readonly kind: "not_found";
20
+ } | {
21
+ readonly kind: "too_large";
22
+ readonly bytes: number | undefined;
23
+ } | {
24
+ readonly kind: "unparsable";
25
+ readonly detail: string;
26
+ };
27
+ /** The kind of a compressed file by its name, or `undefined` for a file that is not one. */
28
+ export declare function archiveKindOf(path: string): ArchiveKind | undefined;
29
+ /** The one entry of a gzip-compressed file: its name with no `.gz`. */
30
+ export declare function gzipEntryName(path: string): string;
31
+ /** Each file of a compressed file, with its decompressed size. */
32
+ export declare function listArchive(kind: ArchiveKind, path: string, content: Buffer): Result<ArchiveEntry[], EntryRefusal>;
33
+ /** The decompressed bytes of one entry, up to `cap`. */
34
+ export declare function readArchiveEntry(kind: ArchiveKind, path: string, content: Buffer, entry: string, cap: number): Result<Buffer, EntryRefusal>;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The entries of a compressed file, for the read tools of a script: a zip, a tar, a gzip-compressed tar, or one
3
+ * gzip-compressed file.
4
+ *
5
+ * Each read runs on the host and holds the bytes in memory, thus each one has a cap. A zip names the size of each
6
+ * entry in its index, thus a list reads no entry and a read decompresses one entry. A tar has no index, thus a list
7
+ * and a read of a `.tar.gz` decompress the whole stream, up to {@link ARCHIVE_SCAN_CAP_BYTES}.
8
+ */
9
+ import { basename } from "node:path";
10
+ import { gunzipSync } from "node:zlib";
11
+ import { unzipSync } from "fflate";
12
+ import { err, ok } from "neverthrow";
13
+ /** The cap of the decompressed stream of a gzip-compressed tar or file that a list or a read decompresses. */
14
+ export const ARCHIVE_SCAN_CAP_BYTES = 64 * 1024 * 1024;
15
+ /** The kind of a compressed file by its name, or `undefined` for a file that is not one. */
16
+ export function archiveKindOf(path) {
17
+ const lower = path.toLowerCase();
18
+ if (lower.endsWith(".zip"))
19
+ return "zip";
20
+ if (lower.endsWith(".tar"))
21
+ return "tar";
22
+ if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz"))
23
+ return "tar-gzip";
24
+ if (lower.endsWith(".gz"))
25
+ return "gzip";
26
+ return undefined;
27
+ }
28
+ /** The one entry of a gzip-compressed file: its name with no `.gz`. */
29
+ export function gzipEntryName(path) {
30
+ return basename(path).slice(0, -".gz".length);
31
+ }
32
+ /** The decompressed bytes of a gzip stream, or `too_large` past `cap`. */
33
+ function gunzip(content, cap) {
34
+ try {
35
+ return ok(gunzipSync(content, { maxOutputLength: cap }));
36
+ }
37
+ catch (cause) {
38
+ if (cause instanceof RangeError)
39
+ return err({ kind: "too_large", bytes: undefined });
40
+ return err({ kind: "unparsable", detail: "the gzip stream is damaged" });
41
+ }
42
+ }
43
+ const TAR_BLOCK = 512;
44
+ /** One text field of a tar header: the bytes up to the first NUL. */
45
+ function tarText(block, start, length) {
46
+ const field = block.subarray(start, start + length);
47
+ const end = field.indexOf(0);
48
+ return field.subarray(0, end === -1 ? field.length : end).toString("utf8");
49
+ }
50
+ /** The `path` record of a pax extended header, which names an entry longer than the header field holds. */
51
+ function paxPath(data) {
52
+ for (const line of data.toString("utf8").split("\n")) {
53
+ const record = line.slice(line.indexOf(" ") + 1);
54
+ if (record.startsWith("path="))
55
+ return record.slice("path=".length);
56
+ }
57
+ return undefined;
58
+ }
59
+ /**
60
+ * Each regular file of a tar, with its data. A GNU long name and a pax `path` record rename the entry that follows
61
+ * them. A directory, a link, and each other special entry is no file, thus it is skipped.
62
+ */
63
+ function tarFiles(tar) {
64
+ const files = [];
65
+ let offset = 0;
66
+ let longName;
67
+ while (offset + TAR_BLOCK <= tar.length) {
68
+ const header = tar.subarray(offset, offset + TAR_BLOCK);
69
+ if (header.every((byte) => byte === 0))
70
+ break;
71
+ const size = Number.parseInt(tarText(header, 124, 12).trim() || "0", 8);
72
+ if (!Number.isSafeInteger(size) || size < 0)
73
+ return err({ kind: "unparsable", detail: "a tar header has no valid size" });
74
+ const dataStart = offset + TAR_BLOCK;
75
+ if (dataStart + size > tar.length)
76
+ return err({ kind: "unparsable", detail: "the tar ends inside an entry" });
77
+ const data = tar.subarray(dataStart, dataStart + size);
78
+ const type = String.fromCharCode(header[156] ?? 0);
79
+ const prefix = tarText(header, 257, 6) === "ustar" ? tarText(header, 345, 155) : "";
80
+ const name = longName ?? (prefix === "" ? tarText(header, 0, 100) : `${prefix}/${tarText(header, 0, 100)}`);
81
+ if (type === "L")
82
+ longName = tarText(data, 0, data.length);
83
+ else if (type === "x")
84
+ longName = paxPath(data) ?? longName;
85
+ else {
86
+ longName = undefined;
87
+ if (type === "0" || type === "\0")
88
+ files.push({ name, data });
89
+ }
90
+ offset = dataStart + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
91
+ }
92
+ return ok(files);
93
+ }
94
+ /** The tar inside a tar archive: the bytes themselves, or the decompressed stream of a `.tar.gz`. */
95
+ function tarOf(kind, content) {
96
+ return kind === "tar" ? ok(content) : gunzip(content, ARCHIVE_SCAN_CAP_BYTES);
97
+ }
98
+ /** The files of a zip, as its index names them. A directory entry is no file. */
99
+ function zipIndex(content) {
100
+ const entries = [];
101
+ try {
102
+ unzipSync(content, {
103
+ filter: (file) => {
104
+ if (!file.name.endsWith("/"))
105
+ entries.push({ name: file.name, bytes: file.originalSize });
106
+ return false;
107
+ },
108
+ });
109
+ }
110
+ catch {
111
+ return err({ kind: "unparsable", detail: "the zip is damaged" });
112
+ }
113
+ return ok(entries);
114
+ }
115
+ /** Each file of a compressed file, with its decompressed size. */
116
+ export function listArchive(kind, path, content) {
117
+ switch (kind) {
118
+ case "zip":
119
+ return zipIndex(content);
120
+ case "gzip": {
121
+ // The last 4 bytes of a gzip stream hold the decompressed size, modulo 2^32.
122
+ const bytes = content.length >= 4 ? content.readUInt32LE(content.length - 4) : 0;
123
+ return ok([{ name: gzipEntryName(path), bytes }]);
124
+ }
125
+ case "tar":
126
+ case "tar-gzip":
127
+ return tarOf(kind, content)
128
+ .andThen(tarFiles)
129
+ .map((files) => files.map((file) => ({ name: file.name, bytes: file.data.length })));
130
+ default: {
131
+ const unhandled = kind;
132
+ throw new Error(`unhandled archive kind: ${String(unhandled)}`);
133
+ }
134
+ }
135
+ }
136
+ /** The decompressed bytes of one entry, up to `cap`. */
137
+ export function readArchiveEntry(kind, path, content, entry, cap) {
138
+ switch (kind) {
139
+ case "zip": {
140
+ const index = zipIndex(content);
141
+ if (index.isErr())
142
+ return err(index.error);
143
+ const found = index.value.find((file) => file.name === entry);
144
+ if (found === undefined)
145
+ return err({ kind: "not_found" });
146
+ if (found.bytes > cap)
147
+ return err({ kind: "too_large", bytes: found.bytes });
148
+ try {
149
+ const data = unzipSync(content, { filter: (file) => file.name === entry })[entry];
150
+ if (data === undefined)
151
+ return err({ kind: "not_found" });
152
+ // The index can lie about the size, thus the cap holds on the bytes too.
153
+ if (data.length > cap)
154
+ return err({ kind: "too_large", bytes: data.length });
155
+ return ok(Buffer.from(data.buffer, data.byteOffset, data.length));
156
+ }
157
+ catch {
158
+ return err({ kind: "unparsable", detail: `the entry ${entry} did not decompress` });
159
+ }
160
+ }
161
+ case "gzip":
162
+ if (entry !== gzipEntryName(path))
163
+ return err({ kind: "not_found" });
164
+ return gunzip(content, cap);
165
+ case "tar":
166
+ case "tar-gzip": {
167
+ const files = tarOf(kind, content).andThen(tarFiles);
168
+ if (files.isErr())
169
+ return err(files.error);
170
+ const found = files.value.find((file) => file.name === entry);
171
+ if (found === undefined)
172
+ return err({ kind: "not_found" });
173
+ if (found.data.length > cap)
174
+ return err({ kind: "too_large", bytes: found.data.length });
175
+ return ok(found.data);
176
+ }
177
+ default: {
178
+ const unhandled = kind;
179
+ throw new Error(`unhandled archive kind: ${String(unhandled)}`);
180
+ }
181
+ }
182
+ }
@@ -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.
@@ -44,9 +45,12 @@ export type ScriptFileToolsDeps = {
44
45
  readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
45
46
  readonly scratch: ScriptScratch;
46
47
  };
47
- /** The rows of one table, the bytes that they came from, and the columns that they keep. */
48
+ /**
49
+ * The rows of one table, the columns that they keep, and the bytes of the file on disk that held them. A read of an
50
+ * entry names the compressed file, thus its hash is the hash of that file.
51
+ */
48
52
  export type TableRows = {
49
- readonly content: Buffer;
53
+ readonly source: Buffer;
50
54
  readonly columns: string[];
51
55
  readonly rows: Row[];
52
56
  };
@@ -58,6 +62,7 @@ export declare function readTableRows(args: {
58
62
  readonly workspaceFs: WorkspaceFilesystem;
59
63
  readonly session: AgentSession;
60
64
  readonly path: string;
65
+ readonly entry?: string;
61
66
  readonly columns: readonly string[] | undefined;
62
67
  readonly tooLarge: string;
63
68
  }): Promise<Result<TableRows, string>>;
@@ -69,6 +74,7 @@ export declare const ReadTableOutputSchema: z.ZodObject<{
69
74
  }, z.core.$strip>;
70
75
  declare const readPdfInput: z.ZodObject<{
71
76
  path: z.ZodString;
77
+ entry: z.ZodOptional<z.ZodString>;
72
78
  firstPage: z.ZodOptional<z.ZodNumber>;
73
79
  lastPage: z.ZodOptional<z.ZodNumber>;
74
80
  }, z.core.$strip>;
@@ -93,7 +99,7 @@ export type DocumentToolsDeps = ScriptFileToolsDeps & {
93
99
  readonly imageScope?: (session: AgentSession) => string | undefined;
94
100
  };
95
101
  export declare function createScriptFileTools(deps: ScriptFileToolsDeps): Tool[];
96
- /** The script tools that read a table and a PDF of the workspace, and write nothing. */
102
+ /** The script tools that read a table, a PDF, and the files of a compressed file of the workspace, and write nothing. */
97
103
  export declare function createReadScriptTools(deps: Pick<ScriptFileToolsDeps, "workspaceFs" | "resolveWorkspaceRoot">): Tool[];
98
104
  /** The tools that make a document in a script: a chart as an SVG file, and a PDF from markdown. */
99
105
  export declare function createDocumentTools(deps: DocumentToolsDeps): Tool[];
@@ -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
+ }