@inflexa-ai/harness 0.33.1 → 0.35.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 (42) hide show
  1. package/dist/agents/conversation-agent.d.ts +7 -3
  2. package/dist/agents/conversation-agent.js +20 -2
  3. package/dist/agents/sandbox/shared.d.ts +4 -9
  4. package/dist/agents/sandbox/shared.js +9 -12
  5. package/dist/config/environment-stores.d.ts +9 -6
  6. package/dist/execution/artifact-record.d.ts +9 -5
  7. package/dist/execution/artifact-record.js +3 -3
  8. package/dist/execution/reconcile-manifest.js +25 -1
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.js +6 -0
  11. package/dist/prompts/sandbox-standards.d.ts +7 -7
  12. package/dist/prompts/sandbox-standards.js +6 -6
  13. package/dist/provenance/collector.js +1 -1
  14. package/dist/provenance/seam.d.ts +23 -2
  15. package/dist/provenance/types.d.ts +9 -4
  16. package/dist/provenance/types.js +7 -2
  17. package/dist/sandbox/client.d.ts +10 -1
  18. package/dist/sandbox/create-sandbox.d.ts +12 -0
  19. package/dist/sandbox/create-sandbox.js +45 -8
  20. package/dist/sandbox/docker-client.d.ts +11 -0
  21. package/dist/sandbox/docker-client.js +19 -5
  22. package/dist/sandbox/farm.d.ts +3 -1
  23. package/dist/sandbox/farm.js +3 -1
  24. package/dist/sandbox/image-packages.d.ts +107 -0
  25. package/dist/sandbox/image-packages.js +107 -0
  26. package/dist/sandbox/k8s-client.d.ts +11 -0
  27. package/dist/sandbox/k8s-client.js +20 -4
  28. package/dist/sandbox/sandbox-error.d.ts +40 -5
  29. package/dist/sandbox/sandbox-error.js +79 -5
  30. package/dist/tools/sandbox/list-available-packages.d.ts +18 -16
  31. package/dist/tools/sandbox/list-available-packages.js +90 -52
  32. package/dist/tools/workspace/edit-file.d.ts +32 -6
  33. package/dist/tools/workspace/edit-file.js +101 -15
  34. package/dist/tools/workspace/mutator.d.ts +68 -32
  35. package/dist/tools/workspace/mutator.js +203 -55
  36. package/dist/tools/workspace/run-exec.d.ts +5 -5
  37. package/dist/tools/workspace/run-exec.js +5 -5
  38. package/dist/tools/workspace/write-file.d.ts +4 -4
  39. package/dist/tools/workspace/write-file.js +14 -6
  40. package/dist/workspace/paths.d.ts +1 -1
  41. package/dist/workspace/paths.js +1 -1
  42. package/package.json +1 -1
@@ -18,7 +18,10 @@
18
18
  * It starts a report thread as a child of the conversation, and the Report
19
19
  * Builder composes the report in that thread.
20
20
  * The workspace read surface (`read_file`, `grep`, `workspace_search`) is
21
- * wired here over the `WorkspaceFilesystem` seam.
21
+ * wired here over the `WorkspaceFilesystem` seam, and the write pair
22
+ * (`write_file`, `edit_file`) over the session-scoped `WorkspaceMutator` —
23
+ * confined to the analysis root, with one provenance session event per
24
+ * successful write.
22
25
  */
23
26
  import type { Pool } from "pg";
24
27
  import type { ExecuteAnalysisInput, ExecuteAnalysisResult } from "../workflows/execute-analysis.js";
@@ -115,8 +118,9 @@ export interface ConversationAgentDeps extends EnvironmentStorePaths {
115
118
  readonly eyes?: AcquireEyes;
116
119
  /**
117
120
  * The provenance seam. `start_report_session` hands it to its spawn, which
118
- * emits the creation of the session at the moment that the child lands. No
119
- * other tool of this roster reads it.
121
+ * emits the creation of the session at the moment that the child lands.
122
+ * The session-scoped mutator behind `write_file` and `edit_file` emits one
123
+ * `write-file` session event for each successful write.
120
124
  */
121
125
  readonly provenance?: ProvenanceSeam;
122
126
  /**
@@ -18,7 +18,10 @@
18
18
  * It starts a report thread as a child of the conversation, and the Report
19
19
  * Builder composes the report in that thread.
20
20
  * The workspace read surface (`read_file`, `grep`, `workspace_search`) is
21
- * wired here over the `WorkspaceFilesystem` seam.
21
+ * wired here over the `WorkspaceFilesystem` seam, and the write pair
22
+ * (`write_file`, `edit_file`) over the session-scoped `WorkspaceMutator` —
23
+ * confined to the analysis root, with one provenance session event per
24
+ * successful write.
22
25
  */
23
26
  import { createWorkingMemory } from "../memory/working-memory.js";
24
27
  import { conversationPrompt } from "../prompts/conversation.js";
@@ -28,7 +31,7 @@ import { searchGeneTool, lookupAnnotationTool, searchInteractionsTool, chemblToo
28
31
  import { createNcbiTools, createChemDbTools } from "../tools/bio/keys.js";
29
32
  // Dependency-bearing tool factories.
30
33
  import { createGeneratePlanTool, createInspectRunTool, createInspectDataProfileTool, createGenerateAnalogyReportTool, createSearchSemanticScholarTool, searchArxivTool, } from "../tools/research/index.js";
31
- import { createFileStatTool, createGrepTool, createListFilesTool, createReadFileTool, createShowPlanTool, createWorkspaceSearchTool, showFileTool, } from "../tools/workspace/index.js";
34
+ import { createEditFileTool, createFileStatTool, createGrepTool, createListFilesTool, createReadFileTool, createSessionWorkspaceMutator, createShowPlanTool, createWorkspaceSearchTool, createWriteFileTool, showFileTool, } from "../tools/workspace/index.js";
32
35
  import { showUserTool } from "../tools/display/index.js";
33
36
  import { createUpdateWorkingMemoryTool } from "../tools/memory/index.js";
34
37
  import { createListAvailablePackagesTool } from "../tools/sandbox/list-available-packages.js";
@@ -46,6 +49,14 @@ export function createConversationAgent(deps) {
46
49
  const workingMemory = createWorkingMemory(pool);
47
50
  const ncbi = createNcbiTools(bioKeys);
48
51
  const chemDb = createChemDbTools(bioKeys, { ...(deps.logger ? { logger: deps.logger } : {}) });
52
+ // The write path of this agent: coordinates resolve per call from the
53
+ // session's analysis scope, the write prefix is the analysis root, and
54
+ // every successful write emits a `write-file` provenance session event.
55
+ const mutator = createSessionWorkspaceMutator({
56
+ resolveWorkspaceRoot: deps.resolveWorkspaceRoot,
57
+ ...(deps.provenance ? { provenance: deps.provenance } : {}),
58
+ ...(deps.logger ? { logger: deps.logger } : {}),
59
+ });
49
60
  const tools = [
50
61
  // Identifier resolution — the ENSG most other bio tools key off.
51
62
  searchGeneTool,
@@ -146,6 +157,13 @@ export function createConversationAgent(deps) {
146
157
  createListFilesTool(workspaceFs),
147
158
  createFileStatTool(workspaceFs),
148
159
  createGrepTool(workspaceFs),
160
+ // Workspace write pair over the session-scoped mutate seam. The working
161
+ // directory of this agent is the analysis root, so a write lands
162
+ // anywhere inside its own analysis tree; `..`, a foreign analysis, and
163
+ // a symlink escape stay refused. No sandbox is involved — the mutator
164
+ // writes host-side, exactly as it does for a sandbox agent.
165
+ createWriteFileTool({ mutator }),
166
+ createEditFileTool({ mutator, workspaceFilesystem: workspaceFs }),
149
167
  // Working memory.
150
168
  createUpdateWorkingMemoryTool(workingMemory, pool),
151
169
  // Host-contributed tools ride the same context and dispatch path as the
@@ -5,8 +5,9 @@
5
5
  * `AgentDefinition` for one sandbox agent (see the harness-durable-runtime spec): SOUL kernel + the
6
6
  * agent's own prompt body + sandbox standards composed into a single
7
7
  * static `systemPrompt` string (no processors at request time, see the harness-providers spec),
8
- * the workspace mutate trio (`execute_command`, `write_file`, `edit_file`)
9
- * bound to the shared `SandboxClient` and the step's `allowedWritePrefix`,
8
+ * the workspace mutate trio — `execute_command` bound to the shared
9
+ * `SandboxClient`, and `write_file` / `edit_file` confined to the step's
10
+ * `allowedWritePrefix` through the host-side `WorkspaceMutator` —,
10
11
  * the workspace read tools (`read_file`, `grep`) over the shared
11
12
  * `WorkspaceFilesystem`, `inspect_data_profile` over the shared `Pool`, and the
12
13
  * bio/literature/context7/run-inspection tools its `meta.tools` allowlist names.
@@ -19,7 +20,7 @@ import type { Pool } from "pg";
19
20
  import type { AgentDefinition } from "../../loop/types.js";
20
21
  import type { ChatProvider, EmbeddingProvider } from "../../providers/types.js";
21
22
  import type { SandboxClient } from "../../sandbox/client.js";
22
- import type { ExtendAnalysisFarm, SandboxRef, ToolchainSource } from "../../sandbox/types.js";
23
+ import type { ExtendAnalysisFarm, SandboxRef } from "../../sandbox/types.js";
23
24
  import type { WorkspaceFilesystem } from "../../workspace/filesystem.js";
24
25
  import type { ProvenanceCollector as LineageCollector } from "../../provenance/collector.js";
25
26
  import { type BioToolKeys } from "../../tools/bio/keys.js";
@@ -90,12 +91,6 @@ export interface SandboxAgentDeps extends EnvironmentStorePaths {
90
91
  * and the package-link prompt layer appends. Without it, neither exists.
91
92
  */
92
93
  readonly extendAnalysisFarm?: ExtendAnalysisFarm;
93
- /**
94
- * The declared toolchain owner. Keys the orient-core environment text.
95
- * Absent keeps the legacy text, thus an old embedder keeps its cached
96
- * prompt prefix.
97
- */
98
- readonly toolchainSource?: ToolchainSource;
99
94
  }
100
95
  /** Per-agent override for the prompt composition and tool surface. */
101
96
  export interface SandboxAgentPromptOptions {
@@ -5,8 +5,9 @@
5
5
  * `AgentDefinition` for one sandbox agent (see the harness-durable-runtime spec): SOUL kernel + the
6
6
  * agent's own prompt body + sandbox standards composed into a single
7
7
  * static `systemPrompt` string (no processors at request time, see the harness-providers spec),
8
- * the workspace mutate trio (`execute_command`, `write_file`, `edit_file`)
9
- * bound to the shared `SandboxClient` and the step's `allowedWritePrefix`,
8
+ * the workspace mutate trio — `execute_command` bound to the shared
9
+ * `SandboxClient`, and `write_file` / `edit_file` confined to the step's
10
+ * `allowedWritePrefix` through the host-side `WorkspaceMutator` —,
10
11
  * the workspace read tools (`read_file`, `grep`) over the shared
11
12
  * `WorkspaceFilesystem`, `inspect_data_profile` over the shared `Pool`, and the
12
13
  * bio/literature/context7/run-inspection tools its `meta.tools` allowlist names.
@@ -134,16 +135,9 @@ function buildWorkspaceTools(deps, readOnly) {
134
135
  ? []
135
136
  : (() => {
136
137
  const mutator = createWorkspaceMutator({
137
- sandboxClient,
138
- sandbox: step.sandbox,
139
138
  workspaceRoot: step.workspaceRoot,
140
139
  analysisId: step.analysisId,
141
- stepId: step.stepId,
142
- workflowId: step.workflowId,
143
140
  workingDir: hostWorkingDir,
144
- sandboxWorkingDir,
145
- nextFunctionId: step.nextFunctionId,
146
- deadlineMs: step.deadlineMs,
147
141
  ...(lineageCollector ? { lineageCollector } : {}),
148
142
  });
149
143
  return [createWriteFileTool({ mutator }), createEditFileTool({ mutator, workspaceFilesystem: workspaceFs, workingDir: hostWorkingDir })];
@@ -189,10 +183,13 @@ function buildWorkspaceTools(deps, readOnly) {
189
183
  export function createSandboxAgent(deps, meta, body, opts = {}) {
190
184
  const appendStandards = opts.appendAnalysisStepStandards ?? true;
191
185
  // The layer order is fixed by the harness-sandbox-agents spec: the agent
192
- // body, the orient core keyed on the declared toolchain, the package-link
193
- // layer only when the seam is bound, then the step standards.
186
+ // body, the orient core keyed on the toolchain of the sandbox client, the
187
+ // package-link layer only when the seam is bound, then the step standards.
188
+ // The client is the one declaration: the environment it provisions and the
189
+ // text that describes it come from the same value, thus a bag cannot
190
+ // describe a toolchain the sandbox does not have.
194
191
  const sandboxLayer = [
195
- sandboxOrientCorePromptFor(deps.toolchainSource),
192
+ sandboxOrientCorePromptFor(deps.sandboxClient.toolchainSource),
196
193
  ...(deps.extendAnalysisFarm ? [sandboxPackageLinkPrompt] : []),
197
194
  ...(appendStandards ? [sandboxAnalysisStepStandardsPrompt] : []),
198
195
  ];
@@ -45,11 +45,14 @@ export interface EnvironmentStorePaths {
45
45
  */
46
46
  readonly farmLockFile?: string;
47
47
  /**
48
- * Host path of the baked image inventory fragment
49
- * (`image-packages.txt`) — the image-owned tools that
50
- * `list_available_packages` merges into its report. Omit when the host
51
- * cannot read one; a missing fragment merges nothing, and that is a
52
- * normal state.
48
+ * Host path of the image inventory record, `image-packages.json` at the
49
+ * root of the package store — the image-owned tools and Node packages
50
+ * that `list_available_packages` merges into its report. Omit when the
51
+ * host's own process sees the store at the container mountpoint, which
52
+ * makes `/mnt/libs/image-packages.json` correct as-is; set it when the
53
+ * host reads the store somewhere else. An absent or invalid record
54
+ * merges nothing, and that is a normal state: a store packed before the
55
+ * record existed carries none, and the rest of the report stays whole.
53
56
  */
54
57
  readonly imagePackagesFile?: string;
55
58
  /**
@@ -81,7 +84,7 @@ export type PoolInventoryRead = {
81
84
  /** One package entry of an inventory section, with the store identity where the source records it. */
82
85
  export interface PoolInventoryPackage {
83
86
  readonly name: string;
84
- /** The pinned version, rendered as `name==version`. Absent for an image-fragment tool. */
87
+ /** The pinned version, rendered as `name==version`. Absent where the source pins none. */
85
88
  readonly version?: string;
86
89
  /** The content-addressed store directory of the package. */
87
90
  readonly storeDir?: string;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Artifact record — the shape post-step deps and the provenance collector
3
- * read for each file the step produced (hash + size + tool provenance +
4
- * write timestamp). Pure data type; lives in its own file because it is
5
- * shared across `execution/` and `provenance/`.
3
+ * read for each file the step produced (hash + size + tool provenance).
4
+ * Pure data type; lives in its own file because it is shared across
5
+ * `execution/` and `provenance/`.
6
6
  */
7
7
  export interface ArtifactRecord {
8
8
  path: string;
@@ -14,6 +14,10 @@ export interface ArtifactRecord {
14
14
  * their provenance comes from the collector.
15
15
  */
16
16
  toolName?: string;
17
- /** ISO 8601 timestamp captured at write time. */
18
- timestamp: string;
17
+ /**
18
+ * The loop's tool-call id of the write — replay-stable (the durably
19
+ * cached model turn minted it), so the provenance bridge can key a
20
+ * deterministic call-activity identifier on it.
21
+ */
22
+ invocationId: string;
19
23
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Artifact record — the shape post-step deps and the provenance collector
3
- * read for each file the step produced (hash + size + tool provenance +
4
- * write timestamp). Pure data type; lives in its own file because it is
5
- * shared across `execution/` and `provenance/`.
3
+ * read for each file the step produced (hash + size + tool provenance).
4
+ * Pure data type; lives in its own file because it is shared across
5
+ * `execution/` and `provenance/`.
6
6
  */
7
7
  export {};
@@ -22,7 +22,7 @@
22
22
  import { stat } from "node:fs/promises";
23
23
  import path from "node:path";
24
24
  import { createNoopLogger } from "../lib/console-logger.js";
25
- import { computeSha256File, hasValidContentHash } from "../lib/fs-helpers.js";
25
+ import { classifyWithinRoot, computeSha256File, hasValidContentHash } from "../lib/fs-helpers.js";
26
26
  import { artifactReconcileDropped, lineageInputDropped } from "../lib/metrics.js";
27
27
  export async function reconcileManifestWithDisk(input) {
28
28
  const { workspaceRoot, resourceId, runId, stepId, agentId, manifest, collector } = input;
@@ -41,6 +41,18 @@ export async function reconcileManifestWithDisk(input) {
41
41
  logger.warn("skipping out-of-bounds entry", { path: entry.path });
42
42
  continue;
43
43
  }
44
+ // Physical bound: the check above compares path strings, and `entry.path`
45
+ // can name a symlink the step planted — the `stat`/`computeSha256File`
46
+ // below both follow it. A real target outside the analysis tree is not
47
+ // attestable; same verdict as the lexical skip above. An `absent`
48
+ // verdict (a dangling link, a missing file) falls through to the stat,
49
+ // which drops it as a phantom. The sandbox is destroyed before
50
+ // reconcile runs, so the tree is quiescent and a checked link cannot be
51
+ // swapped under the hash.
52
+ if ((await classifyWithinRoot(workspaceRoot, absPath)) === "escaped") {
53
+ logger.warn("skipping symlink-escape entry", { path: entry.path });
54
+ continue;
55
+ }
44
56
  let info;
45
57
  try {
46
58
  info = await stat(absPath);
@@ -139,6 +151,18 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
139
151
  collector.dropInput(ref);
140
152
  continue;
141
153
  }
154
+ // Physical bound: the two checks above compare path strings, and a
155
+ // symlink at `hostPath` turns an in-tree string into an out-of-tree
156
+ // target that `stat`/`computeSha256File` would follow. An escaping
157
+ // target is not an attestable edge — drop it from lineage exactly like
158
+ // the lexical out-of-tree reads above; it is not drift. An `absent`
159
+ // verdict (a dangling link) falls through to the ENOENT drop below.
160
+ if ((await classifyWithinRoot(resourceRoot, hostPath)) === "escaped") {
161
+ logger.warn("dropping out-of-tree input from lineage", { ...attestation, hostPath, boundSite: "realpath" });
162
+ lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "symlink-escape" });
163
+ collector.dropInput(ref);
164
+ continue;
165
+ }
142
166
  let info;
143
167
  try {
144
168
  info = await stat(hostPath);
package/dist/index.d.ts CHANGED
@@ -134,9 +134,12 @@ export type { ResolveWorkspaceRoot } from "./workspace/paths.js";
134
134
  export { createSandboxClient } from "./sandbox/create-sandbox.js";
135
135
  export type { CreateSandboxClientConfig, SandboxBackendConfig } from "./sandbox/create-sandbox.js";
136
136
  export type { SandboxClient } from "./sandbox/client.js";
137
+ export { describeSandboxError, SandboxFailure } from "./sandbox/sandbox-error.js";
138
+ export type { SandboxError } from "./sandbox/sandbox-error.js";
137
139
  export type { ExtendAnalysisFarm, FarmLocation, FarmResolution, FarmSource, PackageRequest, PackageRequestOutcome, ResolveAnalysisFarm, ToolchainSource, } from "./sandbox/types.js";
138
140
  export { FARM_LOCK_FILE, FarmLockSchema, readFarmLock, readFarmLockFile } from "./sandbox/farm.js";
139
141
  export type { FarmLock, FarmLockError } from "./sandbox/farm.js";
142
+ export { IMAGE_PACKAGES_FILE } from "./sandbox/image-packages.js";
140
143
  export type { EnvironmentStorePaths, PoolInventoryPackage, PoolInventoryRead, PoolInventorySection } from "./config/environment-stores.js";
141
144
  export { MachineBudgetSchema, ResourceLimitsSchema, ResourcePolicySchema, ResourceSpecSchema, parseResourcePolicy } from "./config/resource-limits.js";
142
145
  export type { MachineBudget, ResourceLimits, ResourcePolicy, ResourceSpec } from "./config/resource-limits.js";
package/dist/index.js CHANGED
@@ -237,9 +237,15 @@ export { createAnalysisPurge } from "./state/purge-analysis.js";
237
237
  // the failure a fixed-width comparand exists to prevent.
238
238
  export { computeInputSignature } from "./execution/input-signature.js";
239
239
  export { createSandboxClient } from "./sandbox/create-sandbox.js";
240
+ // The throw of the client seam. An embedder matches the class and reads the
241
+ // typed variant on `error`, instead of a string match on the message.
242
+ export { describeSandboxError, SandboxFailure } from "./sandbox/sandbox-error.js";
240
243
  // The farm contract: one `inflexa.lock` per farm. An embedder that composes
241
244
  // farms writes this shape, and the mount gate reads it — one schema, no copy.
242
245
  export { FARM_LOCK_FILE, FarmLockSchema, readFarmLock, readFarmLockFile } from "./sandbox/farm.js";
246
+ // The image record: one file name at the root of the store. An embedder joins
247
+ // it onto its own store root for `imagePackagesFile`.
248
+ export { IMAGE_PACKAGES_FILE } from "./sandbox/image-packages.js";
243
249
  export { MachineBudgetSchema, ResourceLimitsSchema, ResourcePolicySchema, ResourceSpecSchema, parseResourcePolicy } from "./config/resource-limits.js";
244
250
  export { createWorkspaceFilesystem } from "./workspace/filesystem.js";
245
251
  // Exec-callback ingress building blocks. Delivery goes through
@@ -22,20 +22,20 @@
22
22
  * `prompts/briefing.ts`), which is composed per dispatch and where per-step
23
23
  * content belongs.
24
24
  *
25
- * The orient-core text keys on the declared `toolchainSource`
26
- * (`sandboxOrientCorePromptFor`). The absent field gives the legacy constant
27
- * untouched, thus an old embedder keeps its cached prefix byte-identical.
28
- * Both variants are composition-time constants, thus each composition stays
29
- * byte-stable across its own steps.
25
+ * The orient-core text keys on the `toolchainSource` of the sandbox client
26
+ * (`sandboxOrientCorePromptFor`). `"store"` gives the legacy constant
27
+ * untouched, thus an embedder that declares no toolchain keeps its cached
28
+ * prefix byte-identical. Both variants are composition-time constants, thus
29
+ * each composition stays byte-stable across its own steps.
30
30
  */
31
31
  import type { ToolchainSource } from "../sandbox/types.js";
32
32
  export declare const sandboxOrientCorePrompt = "# Sandbox Orient-Core\n\nThese apply to every agent that talks to a sandbox.\n\n## Your Briefing Is Authoritative\n\nYour first message is your briefing, composed for this step at the instant it was\ndispatched \u2014 after every step it depends on had finished. It names your task, your\nworking directory and the read-only analysis root, what the input dataset is\n(domain, organism, design, quality concerns, files), and what each upstream step\nproduced and where it put it. Work from it.\n\nDo NOT re-derive what it already gives you: no filesystem hunt for your inputs, no\nre-reading an upstream step whose summary you were handed, no re-deriving the\norganism or the dimensions from raw bytes. Reach further only where the briefing is\ngenuinely thin for what you must do:\n\n- `inspect_data_profile` \u2014 the full dataset profile: `scope:'overview'` for the\n identity and the file census, `scope:'groups'` for the groups and the dimensions\n that vary across them, `scope:'files'` for the members the profiler annotated\n individually. It is the ONLY record of what the input data IS; no file on disk\n carries it. Use it when the briefing's orientation is not enough, or when it\n carried none.\n- `read_file` on an upstream step's summary \u2014 its path is in your briefing \u2014 when\n the excerpt you were handed is not enough to build on.\n- `workspace_search({ query })` \u2014 natural-language search for a file the briefing\n does not name. Optional `type` filter (`input-group` | `input-dimension` |\n `input` | `output` | `summary` | `synthesis`) and `limit` (1-50, default 8).\n Returns paths, descriptions, and metadata \u2014 not file contents. Most input files\n carry no entry of their own; reach them through their group and `list_files`.\n\n## Your Workspace\n\nYour **working directory** \u2014 writable, and your cwd \u2014 and the read-only\n**analysis root** are named in your briefing. Use those paths exactly; do not\ninvent or guess one.\n\n- **Relative paths resolve against your working directory** \u2014 in `read_file`,\n `list_files`, `file_stat`, `grep`, `write_file`, `edit_file`,\n `execute_command`, and any script you run. `output/de.csv` means\n `<working directory>/output/de.csv` everywhere \u2014 a file you write at a\n relative path is read back at the same relative path.\n- **You may write only inside your working directory.** A write outside it\n comes back as an `out_of_prefix` result (no I/O performed); fix the path.\n- The rest of the analysis is mounted **read-only** at the analysis root. Reach\n input data (under `data/inputs/`) and other steps' outputs (under `runs/`)\n with an **absolute** path beneath it.\n- **Absolute analysis-root paths are canonical** \u2014 use one whenever you\n reference a file you didn't create in this step.\n\n`write_file` creates parent directories as needed \u2014 no separate mkdir.\n\n## Scope \u2014 Analysis Starts at Processed Data\n\nThis environment does **tertiary analysis**: turning processed data into\nbiological findings. Read processing \u2014 alignment, quantification, variant\ncalling, peak calling \u2014 belongs upstream, wherever the instrument output was\nhandled. No tool for it is installed, and none can be.\n\nYour inputs are count and expression matrices, called variants, peak sets,\nsignal tracks, and the metadata describing them. When a request arrives as raw\nreads or unprocessed instrument output, name the processed form you need and\nstop. Do not improvise a pipeline, do not substitute a Python reimplementation\nof an aligner or peak caller, and do not treat a missing upstream tool as an\nobstacle to route around \u2014 it is the boundary of the product, not a gap.\n\nData that has already been processed can still be **read**: `samtools`,\n`bcftools`, `tabix`, `bedtools` and `vcftools` are installed so you can\ninspect, subset, and convert existing BAM/CRAM, VCF and BED. Reading evidence at\nsites you already have is in scope; producing the calls is not.\n\n## Environment \u2014 No Network, No Installs\n\n**There is no egress from this sandbox.** Outbound connections are blocked at the\nfirewall, not merely discouraged: every download, API call, package install, and\n`ExperimentHub`/`AnnotationHub`/`models.download_*` style fetch fails outright.\nThere is no proxy, no mirror, and no retry that succeeds. Packages and reference\ndata were staged onto read-only mounts by the host **before** this container\nstarted, and that is the whole of what exists. Work that genuinely requires the\ninternet \u2014 fetching a dataset, querying an external API, literature lookup \u2014 is\ndone **outside** the sandbox through the host's tools, not from your scripts.\n\nSo: check what you need when you need it \u2014 a targeted lookup, never a catalog dump\nup front.\n\n- **Packages** \u2014 before importing a package you are not certain is present, look it\n up with `list_available_packages`, narrowed to the packages you actually intend\n to import. Importing one that isn't installed fails the script; the lookup costs\n one call.\n- **Reference data** \u2014 `list_available_refs` resolves reference data into a real\n path against the store mounted read-only at `/mnt/refs`. **Search by what the\n data IS, not by where you expect it** (\"TF-target regulons\", \"hallmark gene\n sets\", \"mouse\"): the store's directory layout is an installer detail, it differs\n between hosts, and no path you have seen before is guaranteed to exist here.\n Catalogued entries come back with the organism, the format, and the file's\n internal shape, so read the entry and use the reader and species it names rather\n than assuming \u2014 formats vary per dataset, and there is no guarantee any given\n collection ships in the format your tool prefers.\n The store is OPTIONAL and may be absent or hold none of what you want \u2014 a normal\n state, not an error. Never assume a reference path exists and never hardcode one:\n if a reference you need is not in the inventory, say so plainly and proceed with\n what you do have (or state what must be provisioned) rather than inventing a path\n or substituting something else unannounced. Pass reference paths EXPLICITLY to the\n library \u2014 nothing in the image points a library at the store, so a library that\n resolves data by name from an env var (CellTypist reads `$CELLTYPIST_FOLDER`)\n will not find it unless you export that variable yourself, in the same command,\n using a path the inventory actually returned.\n- Do NOT call a library's built-in data fetcher \u2014 `dc.op.collectri()`,\n `dc.op.progeny()`, `dc.op.msigdb()` and every other `dc.op.*()`, CellTypist's\n `models.download_models()`, gseapy's Enrichr library names, `ExperimentHub`/\n `AnnotationHub` \u2014 they all reach for the network and will fail. Load from an\n inventory path instead. Do NOT download data or install packages at runtime.\n\n## CPU Budget and Parallelism\n\nYour briefing names this step's CPU quota and memory limit. The container's\ncore-count calls (`parallel::detectCores()`, `os.cpu_count()`) report the\nquota, so worker counts sized from them are safe.\n\nBLAS/OpenMP thread pools default to **1 thread** (`OMP_NUM_THREADS`,\n`OPENBLAS_NUM_THREADS`, `MKL_NUM_THREADS`, \u2026). A forked worker inherits its\nparent's pool size, so this default is what keeps `mclapply` /\n`multiprocessing` from running workers \u00D7 threads on a quota sized for one of\nthem. The rule: **workers \u00D7 threads-per-worker must not exceed the quota.** A\nmix is fine while the product stays inside it.\n\n- **Fork/worker parallelism** (`mclapply`, `BiocParallel`, `joblib`,\n `multiprocessing`): size the workers from the reported core count and leave\n the thread defaults alone \u2014 there is nothing to raise. `joblib` also\n divides its own thread budget across its workers; do not manage it.\n- **Thread parallelism** (one process: xgboost, WGCNA, data.table, polars, a\n single large BLAS fit): raise the thread variables for that command through\n `execute_command`'s `env`, e.g.\n `env: { \"OMP_NUM_THREADS\": \"4\", \"OPENBLAS_NUM_THREADS\": \"4\" }` on a 4-CPU\n quota.\n\nExceeding the quota does not kill the step \u2014 the cgroup throttles it \u2014 but an\noversubscribed step runs slower and can starve the exec server that runs your\ncommands.\n\n## Output Contract \u2014 Persisted Files Are the Deliverable\n\nThis is a hard requirement, not a convention. **Your deliverable is persisted\nfiles**, nothing else:\n\n- the **script** you wrote, in `scripts/`,\n- the **data it computed** (derived from the input data), in `output/`,\n- any **figures**, in `figures/`.\n\nConclusions are drawn from those computed output files \u2014 never narrated from\n`execute_command` stdout. stdout/stderr are ephemeral; they are gone the\nmoment the command returns. **A step that ends without persisted\nscripts + outputs has produced nothing**, even if its transcript reads as if\nwork was done. In particular: running the analysis as an inline `python -c` /\n`Rscript -e` one-liner and reporting the numbers it printed produces nothing \u2014\nwrite the script to `scripts/` and persist what it computes to `output/`.\n\nA genuine verdict / QC / decision step that has no tabular result may instead\nwrite a short Markdown memo to `output/` (e.g. `output/qc-verdict.md`) stating\nthe finding and the evidence it rests on \u2014 a real persisted artifact. Do not\nfabricate a CSV just to have one.\n\nIf you genuinely cannot fulfill the step \u2014 required input data is missing, a\ntool you need is unavailable, or the environment is broken \u2014 call\n`report_blocker({ reason })` with a clear, specific reason. Do NOT improvise an\ninline result, fabricate outputs, or end on a prose narrative pretending the\nwork was done. `report_blocker` is the honest exit; an empty step that claims\nsuccess is not.\n\n## Skills \u2014 Method Selection and API Details\n\nSkills hold the full decision trees, API references, contrast syntax, worked\nexamples, and domain anti-patterns that do not fit in prompts. Use them\nactively \u2014 they are your authoritative source on methods.\n\n- `skill_search(query)` \u2014 keyword search across the skills available to you.\n Start here when picking a method or verifying an API detail. Examples:\n `skill_search(\"PyDESeq2 contrast syntax\")`,\n `skill_search(\"Leiden clustering resolution\")`.\n- `skill_read(skill, path)` \u2014 read a file from one of your skills, e.g.\n `skill_read(\"bulk-transcriptomics\", \"SKILL.md\")` or\n `skill_read(\"bulk-transcriptomics\", \"references/pydeseq2-api.md\")`.\n\nThe skills you have access to are listed in your agent instructions.\n\n## Context7 \u2014 Documentation Lookup\n\nLook up current documentation via context7 before writing non-trivial code:\n1. `resolve_library_id` with the package name to get the library ID.\n2. `query_docs` with the library ID and the specific function or method.\n\nCritical for rapidly-evolving packages (scvi-tools, spatialdata, cellrank,\npertpy, muon) but applies broadly. Do NOT guess API details from memory \u2014\nverify with context7 or skill references first.\n\n## Command Execution\n\n`execute_command` runs analysis work \u2014 scripts, bioinformatics CLI tools,\nshell pipes, and anything the workspace tools don't express. It starts in your\nworking directory; a relative `cwd` argument is resolved against it.\n\nFor these tasks, prefer the dedicated workspace tools \u2014 they are faster than\nshelling out and don't waste a turn on path discovery:\n\n| Task | Use this tool | Instead of |\n|-|-|-|\n| List files in a directory | `list_files` | `ls` / `ls -la` / `find -name` |\n| Read a text/source/result file | `read_file` | `cat` / `less` |\n| Search file contents by pattern | `grep` | shell `grep` / `rg` |\n| Size / type of a path | `file_stat` | `stat` / `wc -c` |\n\n**Use `execute_command` for everything else**, including:\n\n- **Running scripts** \u2014 `python scripts/run.py`, `Rscript scripts/de.R`,\n `ruff check scripts/foo.py`.\n- **Previewing huge / binary files** \u2014 `wc -l file.tsv`, `zcat file.gz | head`.\n For text files, prefer `read_file` with `headLines` / `tailLines`.\n- **Shell pipes / chaining** \u2014 `sort | uniq -c | sort -rn | head`.\n- **`find` with non-name predicates** \u2014 `find . -mtime -1`, `find . -size +10M`.\n- **Real CLI tools** \u2014 `samtools`, `bcftools`, `tabix`, `bedtools`,\n `vcftools`, version probes like `python -c \"import x; print(x.__version__)\"`.\n\n**Shell behavior to know:**\n\n- `cd` does NOT persist across calls \u2014 each exec starts in your working\n directory. For cross-call state, pass `cwd` or chain with `&&` in one exec.\n- Quote paths with spaces: `head \"data/inputs/My Folder/file.csv\"`. In Python,\n use `pathlib.Path` or `os.path`.\n\n## Editing Files\n\n`edit_file` replaces `old_string` with `new_string`. Read the file first to\nget the exact text. When `replace_all` is false (default), `old_string` must\noccur exactly once \u2014 include surrounding context to make it unique.\n";
33
33
  /**
34
34
  * The orient-core layer for one declared toolchain. `"image"` states that an
35
35
  * acquisition is a host action and directs the agent to report a missing
36
- * package. The absent field gives the legacy constant untouched.
36
+ * package. `"store"` gives the legacy constant untouched.
37
37
  */
38
- export declare function sandboxOrientCorePromptFor(toolchainSource: ToolchainSource | undefined): string;
38
+ export declare function sandboxOrientCorePromptFor(toolchainSource: ToolchainSource): string;
39
39
  /**
40
40
  * The package-link layer — appended to the sandbox system prompt only when
41
41
  * the embedder binds the `ExtendAnalysisFarm` seam. A composition-time
@@ -22,11 +22,11 @@
22
22
  * `prompts/briefing.ts`), which is composed per dispatch and where per-step
23
23
  * content belongs.
24
24
  *
25
- * The orient-core text keys on the declared `toolchainSource`
26
- * (`sandboxOrientCorePromptFor`). The absent field gives the legacy constant
27
- * untouched, thus an old embedder keeps its cached prefix byte-identical.
28
- * Both variants are composition-time constants, thus each composition stays
29
- * byte-stable across its own steps.
25
+ * The orient-core text keys on the `toolchainSource` of the sandbox client
26
+ * (`sandboxOrientCorePromptFor`). `"store"` gives the legacy constant
27
+ * untouched, thus an embedder that declares no toolchain keeps its cached
28
+ * prefix byte-identical. Both variants are composition-time constants, thus
29
+ * each composition stays byte-stable across its own steps.
30
30
  */
31
31
  export const sandboxOrientCorePrompt = `# Sandbox Orient-Core
32
32
 
@@ -291,7 +291,7 @@ sandbox.`), `- **Packages** — before importing a package you are not certain i
291
291
  /**
292
292
  * The orient-core layer for one declared toolchain. `"image"` states that an
293
293
  * acquisition is a host action and directs the agent to report a missing
294
- * package. The absent field gives the legacy constant untouched.
294
+ * package. `"store"` gives the legacy constant untouched.
295
295
  */
296
296
  export function sandboxOrientCorePromptFor(toolchainSource) {
297
297
  return toolchainSource === "image" ? imageToolchainOrientCorePrompt : sandboxOrientCorePrompt;
@@ -209,7 +209,7 @@ export class ProvenanceCollector {
209
209
  producer: {
210
210
  type: "file_tool",
211
211
  tool: artifact.toolName ?? "unknown",
212
- timestamp: artifact.timestamp,
212
+ invocationId: artifact.invocationId,
213
213
  },
214
214
  inputs: [],
215
215
  scriptPath: null,
@@ -95,8 +95,9 @@ type BlockAct = {
95
95
  * One observation of a session.
96
96
  *
97
97
  * The union names one event for each act: the start of a session, the four block operations, the title,
98
- * the derivation, the preview, and the record. Each event carries the analysis and the thread, thus a
99
- * consumer places it with no lookup. The title sits on the document, thus the title event names no block.
98
+ * the derivation, the preview, the record, and the file write of a conversation turn. Each event carries
99
+ * the analysis and the thread, thus a consumer places it with no lookup. The title sits on the document,
100
+ * thus the title event names no block.
100
101
  *
101
102
  * Each member carries what its own site holds at the moment that the act lands, and nothing more. The
102
103
  * derivation carries the chain of the table, because the site pins it and a consumer cannot rebuild it
@@ -150,6 +151,26 @@ export type SessionProvenanceEvent = {
150
151
  threadId: string;
151
152
  versionId: string;
152
153
  replaced: boolean;
154
+ } | {
155
+ /**
156
+ * One file that a conversation turn wrote with a file tool — a write under no run and no
157
+ * step, thus a session observation rather than a collector record. The hash and the size
158
+ * come from the exact bytes written, computed in-process at the write site.
159
+ */
160
+ type: "write-file";
161
+ analysisId: string;
162
+ /** The conversation thread whose turn wrote the file. Absent when the scope carries none. */
163
+ threadId?: string;
164
+ /** Analysis-root-relative path of the landed file. */
165
+ path: string;
166
+ /** SHA-256 of the bytes written. */
167
+ hash: string;
168
+ /** Size in bytes. */
169
+ size: number;
170
+ /** The agent-visible file tool that authored the content (`write_file` / `edit_file`). */
171
+ tool: string;
172
+ /** The loop's tool-call id of the write — the replay-stable identity of the invocation. */
173
+ invocationId: string;
153
174
  };
154
175
  /**
155
176
  * The provenance seam of the harness. An embedder realizes the members that it records, and the harness
@@ -7,11 +7,16 @@
7
7
  * - What script generated it (if any)
8
8
  */
9
9
  import { z } from "zod";
10
- /** File tool write (write_file, edit_file). */
10
+ /**
11
+ * File tool write (write_file, edit_file). Carries the loop's tool-call
12
+ * `invocationId` — replay-stable, and the identity the bridge mixes into the
13
+ * deterministic call-activity QName — instead of a wall-clock timestamp,
14
+ * which would be re-minted on every durable replay.
15
+ */
11
16
  export declare const FileToolProducerSchema: z.ZodObject<{
12
17
  type: z.ZodLiteral<"file_tool">;
13
18
  tool: z.ZodString;
14
- timestamp: z.ZodString;
19
+ invocationId: z.ZodString;
15
20
  }, z.core.$strip>;
16
21
  /** Sandbox command execution (execute_command). */
17
22
  export declare const CommandProducerSchema: z.ZodObject<{
@@ -25,7 +30,7 @@ export declare const CommandProducerSchema: z.ZodObject<{
25
30
  export declare const ProducerSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
26
31
  type: z.ZodLiteral<"file_tool">;
27
32
  tool: z.ZodString;
28
- timestamp: z.ZodString;
33
+ invocationId: z.ZodString;
29
34
  }, z.core.$strip>, z.ZodObject<{
30
35
  type: z.ZodLiteral<"command">;
31
36
  command: z.ZodString;
@@ -66,7 +71,7 @@ export declare const ProvenanceRecordSchema: z.ZodObject<{
66
71
  producer: z.ZodDiscriminatedUnion<[z.ZodObject<{
67
72
  type: z.ZodLiteral<"file_tool">;
68
73
  tool: z.ZodString;
69
- timestamp: z.ZodString;
74
+ invocationId: z.ZodString;
70
75
  }, z.core.$strip>, z.ZodObject<{
71
76
  type: z.ZodLiteral<"command">;
72
77
  command: z.ZodString;
@@ -8,11 +8,16 @@
8
8
  */
9
9
  import { z } from "zod";
10
10
  // ── Producer ────────────────────────────────────────────────────────
11
- /** File tool write (write_file, edit_file). */
11
+ /**
12
+ * File tool write (write_file, edit_file). Carries the loop's tool-call
13
+ * `invocationId` — replay-stable, and the identity the bridge mixes into the
14
+ * deterministic call-activity QName — instead of a wall-clock timestamp,
15
+ * which would be re-minted on every durable replay.
16
+ */
12
17
  export const FileToolProducerSchema = z.object({
13
18
  type: z.literal("file_tool"),
14
19
  tool: z.string(),
15
- timestamp: z.string(),
20
+ invocationId: z.string(),
16
21
  });
17
22
  /** Sandbox command execution (execute_command). */
18
23
  export const CommandProducerSchema = z.object({
@@ -25,8 +25,17 @@
25
25
  * `tasks/derive-table-exec.ts` are the two worked examples. No type carries this
26
26
  * rule yet, thus a new caller must read this paragraph.
27
27
  */
28
- import type { CreateSandboxMeta, ExecEmit, ExecResult, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SubmitExecBody } from "./types.js";
28
+ import type { CreateSandboxMeta, ExecEmit, ExecResult, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SubmitExecBody, ToolchainSource } from "./types.js";
29
29
  export interface SandboxClient {
30
+ /**
31
+ * The declared owner of the sandbox toolchain, as the client was composed
32
+ * with it: `"image"` when the config declared it, `"store"` when the config
33
+ * declared nothing. The mount plan and the orient-core prompt both key on
34
+ * this one value, thus the environment a sandbox gets and the text that
35
+ * describes it cannot disagree. Required rather than optional, because an
36
+ * optional field would carry two meanings, undeclared and unknown.
37
+ */
38
+ readonly toolchainSource: ToolchainSource;
30
39
  /**
31
40
  * DBOS step (`sandbox.create`) — the spawn half of the two-step create
32
41
  * (see the harness-sandbox-exec spec). Launches the sandbox-base container/Job under the
@@ -80,6 +80,18 @@ export interface CreateSandboxClientConfig {
80
80
  * this field existed.
81
81
  */
82
82
  toolchainSource?: ToolchainSource;
83
+ /**
84
+ * The deployment cannot run an analysis without the package store. Under
85
+ * the fact, a farm that fails the `inflexa.lock` gate refuses the create
86
+ * with `farm_unusable`, and neither backend makes a machine. Absent keeps
87
+ * the degrade: the store mounts drop, a warning is logged, and the
88
+ * sandbox reports the store as unavailable. The field names the
89
+ * deployment fact rather than the remediation, so how the harness answers
90
+ * it can evolve without changing the embedder surface; a single-valued
91
+ * union (not a boolean) so future store classes extend it without a
92
+ * breaking change.
93
+ */
94
+ packageStore?: "required";
83
95
  /** Docker: host dir bind-mounted read-only at `/mnt/refs`. */
84
96
  refStorePath?: string;
85
97
  /** Docker: force the container platform (e.g. `linux/amd64`) so the sandbox matches the mounted lib store's arch. */