@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
@@ -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 = {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The files that a session writes outside a run, and the host hook that keeps a copy of each.
3
+ *
4
+ * A run registers its step files through `ArtifactRegistry`. A chat thread and a report session write files too:
5
+ * the scripts and the derived files of `run_script`, and the files of `write_file` and `edit_file`. A host that
6
+ * keeps the files of an analysis elsewhere binds `syncSessionFile`, and each write reaches it with its bytes. The
7
+ * call runs inside the tool step of the write, thus a step that runs again after a crash calls the hook again with
8
+ * the same file.
9
+ */
10
+ import type { ResultAsync } from "neverthrow";
11
+ import type { AgentSession } from "../auth/types.js";
12
+ import { type NoticeFailure } from "../lib/hooks.js";
13
+ import type { Logger } from "../lib/logger.js";
14
+ /** Who owns a session file. A file in the scratch directory of a thread belongs to that thread. */
15
+ export type SessionFileOwner = {
16
+ readonly kind: "thread";
17
+ readonly threadId: string;
18
+ } | {
19
+ readonly kind: "analysis";
20
+ };
21
+ export type SessionFileWrite = {
22
+ /** The session of the tool call that wrote the file. */
23
+ readonly session: AgentSession;
24
+ readonly owner: SessionFileOwner;
25
+ /** The analysis-relative path, and the hash and the size of the bytes. */
26
+ readonly path: string;
27
+ readonly hash: string;
28
+ readonly size: number;
29
+ /** The exact bytes that landed. */
30
+ readonly content: Uint8Array;
31
+ };
32
+ /** A notice: a failure is logged, and the write stays done. */
33
+ export type SyncSessionFile = (write: SessionFileWrite) => ResultAsync<void, NoticeFailure>;
34
+ /** Give one landed write to the hook of the host. An unbound hook does nothing. */
35
+ export declare function syncWrittenFile(sync: SyncSessionFile | undefined, logger: Logger, write: SessionFileWrite): Promise<void>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The files that a session writes outside a run, and the host hook that keeps a copy of each.
3
+ *
4
+ * A run registers its step files through `ArtifactRegistry`. A chat thread and a report session write files too:
5
+ * the scripts and the derived files of `run_script`, and the files of `write_file` and `edit_file`. A host that
6
+ * keeps the files of an analysis elsewhere binds `syncSessionFile`, and each write reaches it with its bytes. The
7
+ * call runs inside the tool step of the write, thus a step that runs again after a crash calls the hook again with
8
+ * the same file.
9
+ */
10
+ import { deliverNotice } from "../lib/hooks.js";
11
+ /** Give one landed write to the hook of the host. An unbound hook does nothing. */
12
+ export async function syncWrittenFile(sync, logger, write) {
13
+ if (sync === undefined)
14
+ return;
15
+ await deliverNotice(logger.with({ analysisId: write.session.scope.analysisId, path: write.path }), "syncSessionFile", sync(write));
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness — an agent loop on the AI SDK, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -83,6 +83,7 @@
83
83
  "dockerode": "^4.0.12",
84
84
  "echarts": "6.1.0",
85
85
  "fast-xml-parser": "^5.10.1",
86
+ "fflate": "^0.8.3",
86
87
  "hono": "^4.12.34",
87
88
  "hyparquet": "^1.28.1",
88
89
  "js-tiktoken": "^1.0.21",