@inflexa-ai/harness 0.26.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,7 @@ import type { Pool } from "pg";
26
26
  import type { AuthContext } from "../auth/types.js";
27
27
  import type { RunAuthorizer } from "../execution/run-authorizer.js";
28
28
  import type { AgentDefinition } from "../loop/types.js";
29
- import type { SandboxClient } from "../sandbox/client.js";
29
+ import type { DeriveTableRunner } from "../tools/report-session/derive-table.js";
30
30
  import type { ReportSessionStateStore } from "../state/report-session-state.js";
31
31
  import type { EmbeddingProvider } from "../providers/types.js";
32
32
  import type { WorkspaceFilesystem } from "../workspace/filesystem.js";
@@ -65,10 +65,11 @@ export interface ReportSessionAgentDeps {
65
65
  /** Derivation ledger -- the derivation tool appends one record for each derived table. */
66
66
  readonly derivations: Pick<ReportSessionStateStore, "appendDerivation">;
67
67
  /**
68
- * Sandbox seam -- the derivation tool runs one ephemeral container for each derived table. Omitted, the
69
- * tool reports that the composition gives no sandbox, and it derives nothing.
68
+ * Derivation-exec seam -- the derivation tool runs one ephemeral container for each derived table, and
69
+ * the composition realizes this seam over a registered workflow. Omitted, the tool reports that the
70
+ * composition gives no sandbox, and it derives nothing.
70
71
  */
71
- readonly sandboxClient?: SandboxClient;
72
+ readonly runDerivation?: DeriveTableRunner;
72
73
  /**
73
74
  * Run-authorization seam -- the derivation tool authorizes the exec and revokes on every terminal path.
74
75
  * Omitted, the tool derives nothing, the same as an absent sandbox client.
@@ -39,7 +39,7 @@ const REPORT_SESSION_MAX_ITERATIONS = 75;
39
39
  /** Build the report `AgentDefinition` with every tool bound to its deps. */
40
40
  export function createReportSessionAgent(deps) {
41
41
  const { model, pool, embedding, workspaceFs, gateway, resolveWorkspaceRoot, store, threads, chrome, eyes, makeResolver, resolvePageAsset, logger } = deps;
42
- const { derivations, sandboxClient, runAuthorizer, makeSessionPages, resolvePageUrl } = deps;
42
+ const { derivations, runDerivation, runAuthorizer, makeSessionPages, resolvePageUrl } = deps;
43
43
  const authoring = createReportAuthoringTools(gateway);
44
44
  const tools = [
45
45
  // The read surface toward the analysis. It roams the tree read-only.
@@ -73,7 +73,7 @@ export function createReportSessionAgent(deps) {
73
73
  gateway,
74
74
  resolveWorkspaceRoot,
75
75
  derivations,
76
- ...(sandboxClient ? { sandboxClient } : {}),
76
+ ...(runDerivation ? { runDerivation } : {}),
77
77
  ...(runAuthorizer ? { runAuthorizer } : {}),
78
78
  ...(logger ? { logger } : {}),
79
79
  }),
@@ -21,7 +21,9 @@
21
21
  * caller's data; it distinguishes the two modes so the message the model
22
22
  * reads matches the mistake it actually made. When neither mode explains the
23
23
  * string it stays silent, because speculative advice to a model that is
24
- * already misdiagnosing is worse than no advice at all.
24
+ * already misdiagnosing is worse than no advice at all. It also measures a
25
+ * string that broke a maximum length, because the Zod message gives the
26
+ * bound and never the length that arrived.
25
27
  * - `repairToolInput` — an **issue-guided** repair: only a path where the
26
28
  * schema wanted an `object` or `array` and found a string is even considered,
27
29
  * so a tool that legitimately declares a `z.string()` field is never touched.
@@ -46,9 +48,12 @@ import type { z } from "zod";
46
48
  * said. `input` is the raw value that failed validation — the same value handed
47
49
  * to `safeParse` — because the issue alone does not carry the offending value.
48
50
  *
49
- * Fires only for an `invalid_type` issue wanting an `object` or an `array` whose
50
- * path resolves to a string, and only when that string demonstrably *is* the
51
- * expected value:
51
+ * Fires for a `too_big` issue on a string, which yields the length that arrived
52
+ * and the count to remove.
53
+ *
54
+ * Fires otherwise only for an `invalid_type` issue wanting an `object` or an
55
+ * `array` whose path resolves to a string, and only when that string
56
+ * demonstrably *is* the expected value:
52
57
  *
53
58
  * 1. it parses in full as JSON of the expected type → double encoding, and
54
59
  * 2. it parses only once a wrapper artifact is stripped → the value is intact
@@ -21,7 +21,9 @@
21
21
  * caller's data; it distinguishes the two modes so the message the model
22
22
  * reads matches the mistake it actually made. When neither mode explains the
23
23
  * string it stays silent, because speculative advice to a model that is
24
- * already misdiagnosing is worse than no advice at all.
24
+ * already misdiagnosing is worse than no advice at all. It also measures a
25
+ * string that broke a maximum length, because the Zod message gives the
26
+ * bound and never the length that arrived.
25
27
  * - `repairToolInput` — an **issue-guided** repair: only a path where the
26
28
  * schema wanted an `object` or `array` and found a string is even considered,
27
29
  * so a tool that legitimately declares a `z.string()` field is never touched.
@@ -55,9 +57,12 @@ const TRAILING_CALL_TAGS = /<\/parameter>\s*(?:<\/invoke>\s*)?$/;
55
57
  * said. `input` is the raw value that failed validation — the same value handed
56
58
  * to `safeParse` — because the issue alone does not carry the offending value.
57
59
  *
58
- * Fires only for an `invalid_type` issue wanting an `object` or an `array` whose
59
- * path resolves to a string, and only when that string demonstrably *is* the
60
- * expected value:
60
+ * Fires for a `too_big` issue on a string, which yields the length that arrived
61
+ * and the count to remove.
62
+ *
63
+ * Fires otherwise only for an `invalid_type` issue wanting an `object` or an
64
+ * `array` whose path resolves to a string, and only when that string
65
+ * demonstrably *is* the expected value:
61
66
  *
62
67
  * 1. it parses in full as JSON of the expected type → double encoding, and
63
68
  * 2. it parses only once a wrapper artifact is stripped → the value is intact
@@ -70,6 +75,9 @@ const TRAILING_CALL_TAGS = /<\/parameter>\s*(?:<\/invoke>\s*)?$/;
70
75
  * `undefined` rather than throwing.
71
76
  */
72
77
  export function hintForZodIssue(issue, input) {
78
+ const overLength = hintForOverLongString(issue, input);
79
+ if (overLength !== undefined)
80
+ return overLength;
73
81
  const expected = repairableExpectation(issue);
74
82
  if (expected === undefined)
75
83
  return undefined;
@@ -123,6 +131,29 @@ export function repairToolInput(input, error) {
123
131
  return undefined;
124
132
  }
125
133
  }
134
+ /**
135
+ * The measurement of a string that broke its maximum length, or `undefined` for
136
+ * any other issue.
137
+ *
138
+ * A Zod `too_big` message names the bound and stops there. A model reasons in
139
+ * tokens, thus it cannot count the characters that it sent, and it cuts by
140
+ * guess. Each guess costs one rejected call. The hint gives the two numbers
141
+ * that end the guess: the length that arrived, and the count to remove.
142
+ */
143
+ function hintForOverLongString(issue, input) {
144
+ if (issue.code !== "too_big" || issue.origin !== "string")
145
+ return undefined;
146
+ const value = resolvePath(input, issue.path);
147
+ if (typeof value !== "string")
148
+ return undefined;
149
+ const maximum = Number(issue.maximum);
150
+ if (!Number.isFinite(maximum))
151
+ return undefined;
152
+ const excess = value.length - maximum;
153
+ if (excess <= 0)
154
+ return undefined;
155
+ return `This argument arrived with ${value.length} characters. Remove at least ${excess} characters, and send it again.`;
156
+ }
126
157
  /** The `object`/`array` expectation of an `invalid_type` issue, if it has one. */
127
158
  function repairableExpectation(issue) {
128
159
  if (issue.code !== "invalid_type")
@@ -28,6 +28,8 @@ import { type SandboxStepDeps, type SandboxStepInput, type SandboxStepResult } f
28
28
  import { type ExecuteTargetAssessmentDeps, type ExecuteTargetAssessmentInput, type ExecuteTargetAssessmentResult } from "../workflows/execute-target-assessment.js";
29
29
  import { type DataProfileDeps, type DataProfileWorkflowInput } from "../tasks/data-profile.js";
30
30
  import { type ExtractValuesResult, type ExtractValuesWorkflowInput } from "../tasks/extract-values.js";
31
+ import type { DeriveTableExecInput } from "../tools/report-session/derive-table.js";
32
+ import type { ExecResult } from "../sandbox/types.js";
31
33
  import { type CitationResolverConfig } from "../citations/resolve.js";
32
34
  import type { CitationResolver } from "../citations/types.js";
33
35
  import type { AuthContext } from "../auth/types.js";
@@ -61,6 +63,7 @@ export interface RegisteredWorkflows {
61
63
  readonly executeTargetAssessment: (input: ExecuteTargetAssessmentInput) => Promise<ExecuteTargetAssessmentResult>;
62
64
  readonly dataProfile: (input: DataProfileWorkflowInput) => Promise<void>;
63
65
  readonly extractValues: (input: ExtractValuesWorkflowInput) => Promise<ExtractValuesResult>;
66
+ readonly deriveTableExec: (input: DeriveTableExecInput) => Promise<ExecResult>;
64
67
  }
65
68
  /**
66
69
  * Conversation-agent deps minus the workflow callable, the resource policy, the
@@ -26,6 +26,7 @@ import { registerSandboxStep } from "../workflows/sandbox-step.js";
26
26
  import { registerExecuteTargetAssessment, } from "../workflows/execute-target-assessment.js";
27
27
  import { registerDataProfileWorkflow } from "../tasks/data-profile.js";
28
28
  import { bindExtractionTrigger, createExtractionArm, registerExtractValuesWorkflow, } from "../tasks/extract-values.js";
29
+ import { registerDeriveTableExecWorkflow, triggerDeriveTableExec } from "../tasks/derive-table-exec.js";
29
30
  import { createCitationResolver } from "../citations/resolve.js";
30
31
  import { createThreadStore } from "../memory/thread-store.js";
31
32
  import { createArtifactReadStore, createProductionResolver } from "../report-model/production-resolver.js";
@@ -95,6 +96,12 @@ export function assembleCoreRuntime(deps) {
95
96
  runAuthorizer: wf.dataProfile.runAuthorizer,
96
97
  ...(wf.dataProfile.logger ? { logger: wf.dataProfile.logger } : {}),
97
98
  });
99
+ // A session derivation runs its container here and not in the turn. The await of an exec is a
100
+ // workflow-body call under the callback transport, thus the tool starts this workflow and awaits it.
101
+ const deriveTableExec = registerDeriveTableExecWorkflow({
102
+ sandboxClient: wf.dataProfile.sandboxClient,
103
+ ...(wf.dataProfile.logger ? { logger: wf.dataProfile.logger } : {}),
104
+ });
98
105
  // The session runtime binds the per-session state to the thread behind the
99
106
  // tool boundary, thus one instance serves every report thread. It comes before
100
107
  // the conversation agent, because `start_report_session` takes its anchor
@@ -159,7 +166,7 @@ export function assembleCoreRuntime(deps) {
159
166
  // and the same run authorizer. Thus a session derivation and an extraction run on
160
167
  // one substrate, and neither one mints a run of the analysis.
161
168
  derivations: reportSession.derivations,
162
- sandboxClient: wf.dataProfile.sandboxClient,
169
+ runDerivation: (input) => triggerDeriveTableExec(deriveTableExec, input),
163
170
  runAuthorizer: conversation.runAuthorizer,
164
171
  makeResolver: makeReportResolver,
165
172
  ...(eyes ? { eyes } : {}),
@@ -188,6 +195,7 @@ export function assembleCoreRuntime(deps) {
188
195
  executeTargetAssessment,
189
196
  dataProfile,
190
197
  extractValues,
198
+ deriveTableExec,
191
199
  },
192
200
  citationResolver,
193
201
  };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `SandboxClient` — the five-method seam every sandbox-backed module in
2
+ * `SandboxClient` — the seam every sandbox-backed module in
3
3
  * the harness sits on. Implementations are backend-selected
4
4
  * (`docker`/`k8s`) by `createSandboxClient()` in `create-sandbox.ts` and
5
5
  * injected at the composition root as a construction-time dependency
@@ -17,20 +17,16 @@
17
17
  * Many execs may fire against the same machine.
18
18
  * - The **exec** lifetime is one `submitExec → awaitExec`.
19
19
  *
20
- * `awaitExec` runs in the workflow body (not as a DBOS step) because
21
- * `DBOS.recv` and `DBOS.writeStream` are body-only.
20
+ * **Every caller of `awaitExec` runs inside a DBOS workflow body.** `DBOS.recv`
21
+ * and `DBOS.writeStream` are body-only, thus the callback transport cannot
22
+ * settle anywhere else, and the poll transport reaches for `DBOS.runStep` and
23
+ * `DBOS.sleepms` defaults. A caller that starts from a live chat turn registers
24
+ * its own workflow and starts it: `tasks/extract-values.ts` and
25
+ * `tasks/derive-table-exec.ts` are the two worked examples. No type carries this
26
+ * rule yet, thus a new caller must read this paragraph.
22
27
  */
23
- import type { CreateSandboxMeta, ExecEmit, ExecResult, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SandboxTransport, SubmitExecBody } from "./types.js";
28
+ import type { CreateSandboxMeta, ExecEmit, ExecResult, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SubmitExecBody } from "./types.js";
24
29
  export interface SandboxClient {
25
- /**
26
- * The result transport this client awaits under, fixed at construction. A caller that runs outside a
27
- * workflow body reads it: `callback` awaits through `DBOS.recv`, which is body-only, thus such a caller
28
- * must refuse before it starts a container it can never await.
29
- *
30
- * Optional, because a hand-written realization states nothing here. Absent means unknown, and a caller
31
- * treats it as the poll default rather than refusing every composition that omits it.
32
- */
33
- readonly transport?: SandboxTransport;
34
30
  /**
35
31
  * DBOS step (`sandbox.create`) — the spawn half of the two-step create
36
32
  * (see the harness-sandbox-exec spec). Launches the sandbox-base container/Job under the
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `SandboxClient` — the five-method seam every sandbox-backed module in
2
+ * `SandboxClient` — the seam every sandbox-backed module in
3
3
  * the harness sits on. Implementations are backend-selected
4
4
  * (`docker`/`k8s`) by `createSandboxClient()` in `create-sandbox.ts` and
5
5
  * injected at the composition root as a construction-time dependency
@@ -17,7 +17,12 @@
17
17
  * Many execs may fire against the same machine.
18
18
  * - The **exec** lifetime is one `submitExec → awaitExec`.
19
19
  *
20
- * `awaitExec` runs in the workflow body (not as a DBOS step) because
21
- * `DBOS.recv` and `DBOS.writeStream` are body-only.
20
+ * **Every caller of `awaitExec` runs inside a DBOS workflow body.** `DBOS.recv`
21
+ * and `DBOS.writeStream` are body-only, thus the callback transport cannot
22
+ * settle anywhere else, and the poll transport reaches for `DBOS.runStep` and
23
+ * `DBOS.sleepms` defaults. A caller that starts from a live chat turn registers
24
+ * its own workflow and starts it: `tasks/extract-values.ts` and
25
+ * `tasks/derive-table-exec.ts` are the two worked examples. No type carries this
26
+ * rule yet, thus a new caller must read this paragraph.
22
27
  */
23
28
  export {};
@@ -127,7 +127,6 @@ export function createSandboxClient(config) {
127
127
  // poll loop's escalation probe are the same backend inspect.
128
128
  const isAlive = async (ref) => unwrapOrThrow(await ops.isAlive(ref));
129
129
  return {
130
- transport,
131
130
  createSandbox: async (meta, identity) => {
132
131
  // The config states an engine fact (binds preserve host ownership);
133
132
  // which remediation that fact demands — today, a world-writable step
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The container of one report-session derivation, as a registered DBOS workflow.
3
+ *
4
+ * The `derive_table` tool runs inside one live chat turn, and a chat turn is not a workflow body. An await
5
+ * of an exec under the callback transport calls `DBOS.recv`, which a workflow body alone can call. Thus the
6
+ * container lives here, behind a registered workflow, and the tool starts it and awaits the handle.
7
+ *
8
+ * The shape is the shape of `tasks/extract-values.ts`: a body that a test drives directly, a registration
9
+ * that closes over the construction-time deps, and a trigger that starts the workflow and gives the result
10
+ * back. The tool holds the trigger as a plain function, thus no module under `tools/` imports DBOS.
11
+ *
12
+ * The body owns the whole machine lifetime. It creates the container, submits one exec, awaits the terminal
13
+ * result, and tears the container down on both paths. A teardown fault reaches the log alone, because the
14
+ * work is done by then.
15
+ */
16
+ import type { Logger } from "../lib/logger.js";
17
+ import type { SandboxClient } from "../sandbox/client.js";
18
+ import type { ExecResult } from "../sandbox/types.js";
19
+ import { type DeriveTableExecInput } from "../tools/report-session/derive-table.js";
20
+ /** The construction-time deps of the body. The registration closes over them, thus the trigger holds none. */
21
+ export interface DeriveTableExecDeps {
22
+ readonly sandboxClient: SandboxClient;
23
+ /**
24
+ * The checkpointed clock of the deadline. It defaults to `DBOS.now()`, which a replay reads again from
25
+ * the checkpoint. A test injects a fixed clock, thus it drives this body with no launched runtime.
26
+ */
27
+ readonly now?: () => Promise<number>;
28
+ readonly logger?: Logger;
29
+ }
30
+ /**
31
+ * The body, exported so a test drives it without a registered workflow.
32
+ *
33
+ * A fault of any seam call throws. The tool reads a rejection as one short detail, thus the caller needs no
34
+ * result type here.
35
+ */
36
+ export declare function runDeriveTableExecBody(input: DeriveTableExecInput, deps: DeriveTableExecDeps): Promise<ExecResult>;
37
+ /**
38
+ * Register the derivation workflow with DBOS. It gives back the registered callable, thus a trigger
39
+ * dispatches with `DBOS.startWorkflow`.
40
+ */
41
+ export declare function registerDeriveTableExecWorkflow(deps: DeriveTableExecDeps): (input: DeriveTableExecInput) => Promise<ExecResult>;
42
+ /** The per-attempt workflow id. The execution id is unique already, and the nonce keeps a retry distinct. */
43
+ export declare function deriveTableExecWorkflowId(executionId: string, nonce: string): string;
44
+ /**
45
+ * The async edge. It starts the registered workflow and it awaits the terminal result.
46
+ *
47
+ * The tool authorizes the derivation before this call and it revokes after, thus this edge holds no
48
+ * lifecycle of its own. A workflow fault rejects the returned promise.
49
+ */
50
+ export declare function triggerDeriveTableExec(workflow: (input: DeriveTableExecInput) => Promise<ExecResult>, input: DeriveTableExecInput): Promise<ExecResult>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The container of one report-session derivation, as a registered DBOS workflow.
3
+ *
4
+ * The `derive_table` tool runs inside one live chat turn, and a chat turn is not a workflow body. An await
5
+ * of an exec under the callback transport calls `DBOS.recv`, which a workflow body alone can call. Thus the
6
+ * container lives here, behind a registered workflow, and the tool starts it and awaits the handle.
7
+ *
8
+ * The shape is the shape of `tasks/extract-values.ts`: a body that a test drives directly, a registration
9
+ * that closes over the construction-time deps, and a trigger that starts the workflow and gives the result
10
+ * back. The tool holds the trigger as a plain function, thus no module under `tools/` imports DBOS.
11
+ *
12
+ * The body owns the whole machine lifetime. It creates the container, submits one exec, awaits the terminal
13
+ * result, and tears the container down on both paths. A teardown fault reaches the log alone, because the
14
+ * work is done by then.
15
+ */
16
+ import { DBOS } from "@dbos-inc/dbos-sdk";
17
+ import { randomUUID } from "node:crypto";
18
+ import { createNoopLogger } from "../lib/console-logger.js";
19
+ import { mintSandboxIdentity } from "../sandbox/identity.js";
20
+ import { buildDerivationExec, DERIVATION_DEADLINE_MS, DERIVATION_RESOURCES, DERIVE_RUN_LITERAL, DERIVE_STEP_LITERAL, } from "../tools/report-session/derive-table.js";
21
+ /** The awaitExec callback. A derivation reports no live activity, thus the callback drops each event. */
22
+ const noopEmit = () => { };
23
+ /**
24
+ * The body, exported so a test drives it without a registered workflow.
25
+ *
26
+ * A fault of any seam call throws. The tool reads a rejection as one short detail, thus the caller needs no
27
+ * result type here.
28
+ */
29
+ export async function runDeriveTableExecBody(input, deps) {
30
+ const logger = (deps.logger ?? createNoopLogger()).named("derive-table-exec").with({ analysisId: input.analysisId });
31
+ const workflowId = DBOS.workflowID ?? `${DERIVE_RUN_LITERAL}:${input.executionId}`;
32
+ const sandbox = await deps.sandboxClient.createSandbox({
33
+ runId: DERIVE_RUN_LITERAL,
34
+ stepId: DERIVE_STEP_LITERAL,
35
+ analysisId: input.analysisId,
36
+ childWorkflowId: workflowId,
37
+ resources: DERIVATION_RESOURCES,
38
+ writableTail: input.writableTail,
39
+ }, mintSandboxIdentity(DERIVE_RUN_LITERAL));
40
+ try {
41
+ // A checkpointed clock, not `Date.now()`: the await gates on this absolute deadline, and a
42
+ // wall-clock deadline that grew on replay would shift which loop iteration crosses it.
43
+ const deadline = (await (deps.now ?? (() => DBOS.now()))()) + DERIVATION_DEADLINE_MS;
44
+ await deps.sandboxClient.submitExec(sandbox, buildDerivationExec({
45
+ script: input.script,
46
+ execId: input.executionId,
47
+ workingDir: input.workingDir,
48
+ inputs: input.inputs,
49
+ output: input.output,
50
+ }));
51
+ return await deps.sandboxClient.awaitExec(sandbox, input.executionId, noopEmit, deadline);
52
+ }
53
+ finally {
54
+ try {
55
+ await deps.sandboxClient.teardown(sandbox);
56
+ }
57
+ catch (cause) {
58
+ logger.warn("the derivation sandbox did not tear down", logger.errorFields(cause));
59
+ }
60
+ }
61
+ }
62
+ /**
63
+ * Register the derivation workflow with DBOS. It gives back the registered callable, thus a trigger
64
+ * dispatches with `DBOS.startWorkflow`.
65
+ */
66
+ export function registerDeriveTableExecWorkflow(deps) {
67
+ return DBOS.registerWorkflow((input) => runDeriveTableExecBody(input, deps), { name: "derive-table-exec" });
68
+ }
69
+ /** The per-attempt workflow id. The execution id is unique already, and the nonce keeps a retry distinct. */
70
+ export function deriveTableExecWorkflowId(executionId, nonce) {
71
+ return `${DERIVE_RUN_LITERAL}:${executionId}:${nonce}`;
72
+ }
73
+ /**
74
+ * The async edge. It starts the registered workflow and it awaits the terminal result.
75
+ *
76
+ * The tool authorizes the derivation before this call and it revokes after, thus this edge holds no
77
+ * lifecycle of its own. A workflow fault rejects the returned promise.
78
+ */
79
+ export async function triggerDeriveTableExec(workflow, input) {
80
+ const handle = (await DBOS.startWorkflow(workflow, {
81
+ workflowID: deriveTableExecWorkflowId(input.executionId, randomUUID()),
82
+ })(input));
83
+ return handle.getResult();
84
+ }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * A session derivation is not an analysis run. It mints no run id, it registers no artifact, and it writes
9
9
  * under the session directory alone. The rails are the rails of the value tier (`tasks/extract-values.ts`):
10
- * the sandbox client, the identity mint, and the authorizer on every terminal path. The container is
10
+ * the injected exec runner, the identity mint, and the authorizer on every terminal path. The container is
11
11
  * ephemeral, and it goes away with the work.
12
12
  *
13
13
  * The container mounts the analysis tree read-only, and one write mount covers the `derived/` directory of
@@ -29,15 +29,14 @@
29
29
  * condition one time, up front, the same discipline as the eyes. A per-attempt failure would instead read as
30
30
  * a transient fault, and it would invite a repeat of a call that can never pass.
31
31
  *
32
- * The transport of the client is the second such bound. A derivation runs inside one live turn and not
33
- * inside a workflow body. The poll transport awaits with plain steps and a plain sleep, thus it settles
34
- * there. The callback transport awaits through `DBOS.recv`, which a workflow body alone can call, thus this
35
- * tool refuses up front under it rather than start a container that nothing can await.
32
+ * The container runs behind a seam, and never in the turn. An await of an exec is a workflow-body call
33
+ * under the callback transport, and a report turn is not a body. Thus the tool takes an injected runner,
34
+ * and a registered workflow owns the container. The tool holds no sandbox client, and it imports no DBOS.
36
35
  */
37
36
  import { z } from "zod";
38
37
  import type { RunAuthorizer } from "../../execution/run-authorizer.js";
39
38
  import { type Logger } from "../../lib/logger.js";
40
- import type { SandboxClient } from "../../sandbox/client.js";
39
+ import type { ResourceSpec } from "../../config/resource-limits.js";
41
40
  import type { ExecResult, SubmitExecBody } from "../../sandbox/types.js";
42
41
  import type { DerivationSource, ReportSessionStateStore } from "../../state/report-session-state.js";
43
42
  import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
@@ -113,10 +112,20 @@ export interface DeriveTableToolDeps {
113
112
  readonly gateway: ReportSessionStateGateway;
114
113
  readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
115
114
  readonly derivations: Pick<ReportSessionStateStore, "appendDerivation">;
116
- readonly sandboxClient?: SandboxClient;
115
+ readonly runDerivation?: DeriveTableRunner;
117
116
  readonly runAuthorizer?: RunAuthorizer;
118
117
  readonly logger?: Logger;
119
118
  }
119
+ /** The synthetic run id and step id of one derivation. Both are constants, and neither names a run row. */
120
+ export declare const DERIVE_RUN_LITERAL: "derive-table";
121
+ export declare const DERIVE_STEP_LITERAL: "derive";
122
+ /** The exec budget of one derivation. It matches the budget of the value tier. */
123
+ export declare const DERIVATION_DEADLINE_MS = 300000;
124
+ /**
125
+ * The container size of one derivation. A join or a pivot loads each declared input into pandas at one
126
+ * time, thus the container needs the headroom of the value tier.
127
+ */
128
+ export declare const DERIVATION_RESOURCES: ResourceSpec;
120
129
  /** The environment variable that carries the declared inputs into the container. */
121
130
  export declare const DERIVE_INPUT_ENV = "DERIVE_INPUTS";
122
131
  /** The environment variable that carries the container path of the output file into the container. */
@@ -152,6 +161,29 @@ export declare function buildDerivationExec(args: {
152
161
  * The function is pure, thus a test asserts the mapping without a sandbox.
153
162
  */
154
163
  export declare function describeExecFailure(result: ExecResult): string | undefined;
164
+ /**
165
+ * The exec of one derivation, as a registered workflow reads it.
166
+ *
167
+ * Each field is plain data, thus the value crosses the workflow boundary and it survives a replay. The
168
+ * paths are container paths already: the host maps them one time, before the start.
169
+ */
170
+ export interface DeriveTableExecInput {
171
+ readonly analysisId: string;
172
+ readonly executionId: string;
173
+ readonly script: string;
174
+ readonly writableTail: string;
175
+ readonly workingDir: string;
176
+ readonly inputs: readonly DerivationInputMount[];
177
+ readonly output: string;
178
+ }
179
+ /**
180
+ * Run one derivation exec and give the terminal result back.
181
+ *
182
+ * The composition realizes it over a registered workflow, thus the container lives inside a workflow body
183
+ * and the await is legal under each transport. A fault of the sandbox rejects the promise, and the tool
184
+ * turns that rejection into one short detail.
185
+ */
186
+ export type DeriveTableRunner = (input: DeriveTableExecInput) => Promise<ExecResult>;
155
187
  /**
156
188
  * Make the derivation tool over the session-state gateway, the derivation ledger, and the sandbox rails.
157
189
  *
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * A session derivation is not an analysis run. It mints no run id, it registers no artifact, and it writes
9
9
  * under the session directory alone. The rails are the rails of the value tier (`tasks/extract-values.ts`):
10
- * the sandbox client, the identity mint, and the authorizer on every terminal path. The container is
10
+ * the injected exec runner, the identity mint, and the authorizer on every terminal path. The container is
11
11
  * ephemeral, and it goes away with the work.
12
12
  *
13
13
  * The container mounts the analysis tree read-only, and one write mount covers the `derived/` directory of
@@ -29,12 +29,11 @@
29
29
  * condition one time, up front, the same discipline as the eyes. A per-attempt failure would instead read as
30
30
  * a transient fault, and it would invite a repeat of a call that can never pass.
31
31
  *
32
- * The transport of the client is the second such bound. A derivation runs inside one live turn and not
33
- * inside a workflow body. The poll transport awaits with plain steps and a plain sleep, thus it settles
34
- * there. The callback transport awaits through `DBOS.recv`, which a workflow body alone can call, thus this
35
- * tool refuses up front under it rather than start a container that nothing can await.
32
+ * The container runs behind a seam, and never in the turn. An await of an exec is a workflow-body call
33
+ * under the callback transport, and a report turn is not a body. Thus the tool takes an injected runner,
34
+ * and a registered workflow owns the container. The tool holds no sandbox client, and it imports no DBOS.
36
35
  */
37
- import { err, ok } from "neverthrow";
36
+ import { ok } from "neverthrow";
38
37
  import { unlink } from "node:fs/promises";
39
38
  import { join } from "node:path";
40
39
  import { z } from "zod";
@@ -43,7 +42,6 @@ import { classifyWithinRoot, computeSha256, computeSha256File } from "../../lib/
43
42
  import { defaultErrorFields } from "../../lib/logger.js";
44
43
  import { snapshotEntry } from "../../report-model/reference-resolver.js";
45
44
  import { generateExecutionId } from "../../sandbox/execution-id.js";
46
- import { mintSandboxIdentity } from "../../sandbox/identity.js";
47
45
  import { isSafeId, reportSessionDerivedDir, toSandboxPath } from "../../workspace/paths.js";
48
46
  import { defineTool } from "../define-tool.js";
49
47
  import { openReportThread } from "../report-authoring/authoring-tools.js";
@@ -55,17 +53,17 @@ const deriveTableInput = z.object({
55
53
  output: z.string().min(1),
56
54
  });
57
55
  /** The synthetic run id and step id of one derivation. Both are constants, and neither names a run row. */
58
- const DERIVE_RUN_LITERAL = "derive-table";
59
- const DERIVE_STEP_LITERAL = "derive";
56
+ export const DERIVE_RUN_LITERAL = "derive-table";
57
+ export const DERIVE_STEP_LITERAL = "derive";
60
58
  /** The provenance agent id. No agent loop runs in the container, thus this names the derivation pass. */
61
59
  const DERIVE_AGENT_ID = "table-deriver";
62
60
  /** The exec budget of one derivation. It matches the budget of the value tier. */
63
- const DERIVATION_DEADLINE_MS = 300_000;
61
+ export const DERIVATION_DEADLINE_MS = 300_000;
64
62
  /**
65
63
  * The container size of one derivation. A join or a pivot loads each declared input into pandas at one
66
64
  * time, thus the container needs the headroom of the value tier.
67
65
  */
68
- const DERIVATION_RESOURCES = { cpu: 2, memoryGb: 8 };
66
+ export const DERIVATION_RESOURCES = { cpu: 2, memoryGb: 8 };
69
67
  /** The cap of the script, in bytes. A reshaping script sits far under it, and the command line carries it. */
70
68
  const SCRIPT_CAP_BYTES = 64 * 1024;
71
69
  /** The cap of the declared inputs. One derivation reads the evidence of a few artifacts, and never a tree. */
@@ -76,12 +74,8 @@ export const DERIVE_INPUT_ENV = "DERIVE_INPUTS";
76
74
  export const DERIVE_OUTPUT_ENV = "DERIVE_OUTPUT";
77
75
  /** The cap of the failure detail that the tool copies out of the standard error of the script. */
78
76
  const STDERR_TAIL_CAP = 2_000;
79
- /** The awaitExec callback. A derivation reports no live activity, thus the callback drops each event. */
80
- const noopEmit = () => { };
81
77
  /** The line that the agent reads when the composition binds no sandbox rails. */
82
78
  const NO_SANDBOX_DETAIL = "the composition gives no sandbox, thus this session cannot derive a table";
83
- /** The line that the agent reads when the sandbox client awaits under a transport that a turn cannot drive. */
84
- const CALLBACK_TRANSPORT_DETAIL = "the sandbox of this composition reports through callbacks, which a report turn cannot await, thus it derives no table";
85
79
  /**
86
80
  * Build the sandbox exec of one derivation. The command runs the script of the agent through `python3 -c`.
87
81
  *
@@ -126,58 +120,6 @@ export function describeExecFailure(result) {
126
120
  }
127
121
  return undefined;
128
122
  }
129
- /**
130
- * Run one derivation in an ephemeral container, and give the exec result back.
131
- *
132
- * The container mounts the analysis tree read-only, and the declared write tail is its one writable mount.
133
- * Thus the script reads the evidence and it writes into the `derived/` directory of the session and nowhere
134
- * else. The teardown runs on both paths, thus no machine outlives the call. A teardown fault reaches the log
135
- * alone, because the work is done.
136
- *
137
- * Each seam call is guarded, thus a fault of the sandbox becomes a short detail and the tool keeps its
138
- * no-throw contract.
139
- */
140
- async function runDerivationExec(args) {
141
- let sandbox;
142
- try {
143
- sandbox = await args.client.createSandbox({
144
- runId: DERIVE_RUN_LITERAL,
145
- stepId: DERIVE_STEP_LITERAL,
146
- analysisId: args.analysisId,
147
- childWorkflowId: `${DERIVE_RUN_LITERAL}:${args.executionId}`,
148
- resources: DERIVATION_RESOURCES,
149
- writableTail: args.writableTail,
150
- }, mintSandboxIdentity(DERIVE_RUN_LITERAL));
151
- }
152
- catch (cause) {
153
- args.logger.error("the derivation sandbox did not start", args.logger.errorFields(cause));
154
- return err("the derivation sandbox did not start");
155
- }
156
- try {
157
- // The tool runs inside one live turn and it never replays, thus the wall clock is the deadline.
158
- const deadline = Date.now() + DERIVATION_DEADLINE_MS;
159
- await args.client.submitExec(sandbox, buildDerivationExec({
160
- script: args.script,
161
- execId: args.executionId,
162
- workingDir: args.workingDir,
163
- inputs: args.inputs,
164
- output: args.output,
165
- }));
166
- return ok(await args.client.awaitExec(sandbox, args.executionId, noopEmit, deadline));
167
- }
168
- catch (cause) {
169
- args.logger.error("the derivation exec did not complete", args.logger.errorFields(cause));
170
- return err("the derivation exec did not complete");
171
- }
172
- finally {
173
- try {
174
- await args.client.teardown(sandbox);
175
- }
176
- catch (cause) {
177
- args.logger.warn("the derivation sandbox did not tear down", args.logger.errorFields(cause));
178
- }
179
- }
180
- }
181
123
  /**
182
124
  * Make the derivation tool over the session-state gateway, the derivation ledger, and the sandbox rails.
183
125
  *
@@ -186,7 +128,7 @@ async function runDerivationExec(args) {
186
128
  */
187
129
  export function createDeriveTableTool(deps) {
188
130
  const logger = (deps.logger ?? createNoopLogger()).named("derive-table");
189
- const { sandboxClient, runAuthorizer } = deps;
131
+ const { runDerivation, runAuthorizer } = deps;
190
132
  return defineTool({
191
133
  id: "derive_table",
192
134
  description: "Derive one table from the pinned evidence with a Python script, and pin the result to this session. " +
@@ -209,16 +151,10 @@ export function createDeriveTableTool(deps) {
209
151
  execute: async (input, ctx) => {
210
152
  // The check runs before every read, thus one clear signal replaces a failure for each attempt.
211
153
  // It also narrows the two rails below, thus the exec needs no assertion.
212
- if (sandboxClient === undefined || runAuthorizer === undefined) {
154
+ if (runDerivation === undefined || runAuthorizer === undefined) {
213
155
  logger.warn("the composition gives no sandbox, thus no derivation can run");
214
156
  return ok({ outcome: "unavailable", detail: NO_SANDBOX_DETAIL });
215
157
  }
216
- // The await of the callback transport is a workflow-body call, and a report turn is not one. An
217
- // absent field states nothing, thus it reads as the poll default and it refuses nothing.
218
- if (sandboxClient.transport === "callback") {
219
- logger.warn("the sandbox client awaits through callbacks, thus no derivation can run in a turn");
220
- return ok({ outcome: "unavailable", detail: CALLBACK_TRANSPORT_DETAIL });
221
- }
222
158
  const opened = await openReportThread(deps.gateway, ctx.session.scope);
223
159
  if (opened.isErr()) {
224
160
  return ok({ outcome: "refused", refusal: opened.error });
@@ -304,7 +240,7 @@ export function createDeriveTableTool(deps) {
304
240
  let result;
305
241
  try {
306
242
  result = await derive({
307
- client: sandboxClient,
243
+ runDerivation,
308
244
  derivations: deps.derivations,
309
245
  root,
310
246
  threadId,
@@ -363,8 +299,9 @@ async function derive(args) {
363
299
  return { outcome: "exec-failed", detail: `${args.outputName} already holds a file that did not clear, thus this derivation takes a new name` };
364
300
  }
365
301
  }
366
- const executed = await runDerivationExec({
367
- client: args.client,
302
+ // The path mapper refuses a stored path that escapes the root, with a throw. It runs before the try,
303
+ // thus that refusal stays a throw and it never reads as a fault of the container.
304
+ const execInput = {
368
305
  analysisId: args.analysisId,
369
306
  executionId,
370
307
  script: args.script,
@@ -372,12 +309,16 @@ async function derive(args) {
372
309
  workingDir,
373
310
  inputs: args.sources.map((source) => ({ path: toSandboxPath(args.root, args.analysisId, join(args.root, source.path)), hash: source.hash })),
374
311
  output: toSandboxPath(args.root, args.analysisId, absolute),
375
- logger: args.logger,
376
- });
377
- if (executed.isErr()) {
378
- return { outcome: "exec-failed", detail: executed.error };
312
+ };
313
+ let executed;
314
+ try {
315
+ executed = await args.runDerivation(execInput);
316
+ }
317
+ catch (cause) {
318
+ args.logger.error("the derivation exec did not complete", { threadId: args.threadId, ...defaultErrorFields(cause) });
319
+ return { outcome: "exec-failed", detail: "the derivation exec did not complete" };
379
320
  }
380
- const failure = describeExecFailure(executed.value);
321
+ const failure = describeExecFailure(executed);
381
322
  if (failure !== undefined) {
382
323
  return { outcome: "exec-failed", detail: failure };
383
324
  }
@@ -27,16 +27,20 @@ import { defineTool } from "./define-tool.js";
27
27
  * message row, thus the schema holds no unbounded string out of the store.
28
28
  *
29
29
  * The description caps the brief at approximately 2000 tokens, and a token is
30
- * about four characters. Thus the five bounds sum to 8000 characters, and the
30
+ * about four characters. Thus the five bounds sum to 8200 characters, and the
31
31
  * widest brief that the schema admits still meets the cap that the agent reads.
32
32
  *
33
33
  * The share of each field follows its role. `audience` names one reader, thus a
34
34
  * phrase is enough. `objective` and `angle` carry a sentence or two. Each
35
35
  * optional field carries a list of items, thus it takes the largest share.
36
+ *
37
+ * The bound of `audience` holds 400 characters, not the 200 that the phrase
38
+ * alone needs. A model counts tokens, not characters, thus a tight bound gives
39
+ * a rejected call and a retry.
36
40
  */
37
41
  const BRIEF_MAX = {
38
42
  objective: 1500,
39
- audience: 200,
43
+ audience: 400,
40
44
  angle: 1500,
41
45
  exclusions: 2400,
42
46
  openQuestions: 2400,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",