@parcha/agentrun-pi 0.1.0-beta.21 → 0.1.0-beta.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # agent.run() + Pi
2
2
 
3
- Describe a task in Pi, inspect the workflow, run it with your current model, and save its definition for new input. Requires Node 22.19+ and Pi 1.0.4 to below 1.4 (CI tests 1.0.4, 1.1.0, 1.2.0 and 1.3.0).
3
+ Describe a task in Pi, inspect the workflow, run it with your current model, and save its definition for new input. Requires Node 22.19+ and Pi 1.0.4 to below 1.4 (CI tests 1.0.4, 1.1.0, 1.2.0 and 1.3.0). Pi 1.4 is not supported yet; the next release widens the range. Pin the peers you install to `@1.3.0` (the install lines below do): an unpinned install of Pi's packages now resolves 1.4.0, which this release does not support, and an unpinned `@earendil-works/pi-durable` or `@earendil-works/chord` fails with `ERESOLVE` against this package's peer range.
4
4
 
5
5
  ## Install
6
6
 
@@ -69,7 +69,7 @@ After inspecting a definition, save it and reuse it in a new Pi session in the s
69
69
  /agentrun run
70
70
  ```
71
71
 
72
- Loading starts with empty input and does not execute. A save retains the definition, not input or execution permission. Scripted demo adapters are not saved: loading a saved demo uses live adapters and requires the corresponding model and Jev access. Each run restarts with the current input and model; it is not checkpoint resume.
72
+ Loading starts with empty input and does not execute. A save retains the definition, not input or execution permission. Scripted demo adapters are not saved: loading a saved demo uses live adapters and requires the corresponding model and Jev access. Loading a saved definition starts a new run with the current input and model. A run that was interrupted continues with `/agentrun run` (see the durable run below).
73
73
 
74
74
  | Command | What happens |
75
75
  | --- | --- |
@@ -260,9 +260,15 @@ npx agentrun-pi validate workflow.json --inputs notes
260
260
 
261
261
  `--inputs` names the keys of the workflow's input object; `review-release-notes.json` takes one, `notes`. `demo` needs no key and no peers. `author` and `run` import a trusted caller-owned `--config` module (its code runs when imported) that exports your runner options, and need the peers listed under Dependencies. `validate` can evaluate code probes. Review executable workflows before using it.
262
262
 
263
+ ## Durable workflows on pi-durable
264
+
265
+ `@parcha/agentrun-pi/durable` runs a workflow as one pi-durable task, `agentrun.workflow`. `createWorkflowTool(host)` gives a pi-durable session the `agentrun` tool and the task its runs are; `createWorkflowExtension(host)` is the task alone, with `start`, `read` and `stop` for your own code. Install pi-durable and chord beside the package at the tested version: `npm install @earendil-works/pi-durable@1.3.0 @earendil-works/chord@1.3.0`. The `host` object supplies what a run needs (its tools, its node runner, its judge, an optional `reconcile`); see [the agentrun tool in a pi-durable session](../../docs/host-integration.md#the-agentrun-tool-in-a-pi-durable-session) and [Recovery and versions](../../docs/host-integration.md#recovery-and-versions). The code below is a description, not a sample: the host is yours.
266
+
267
+ A run keeps its journal in the session's store. A process that is killed leaves the task pending, and `harness.resume()` in the next process runs the workflow again: committed nodes and effects are answered from the journal and not run again, and the run continues at the first step that did not commit. A typed question is a different matter: a route's answer is kept by the recovery wrapper itself, but any other question (`judge`, `pick`, `sift`, `ask`, `verify`) is answered again from its receipt only when the host wires both hooks of `decisionRecovery({ key, runtime, context })` (`recordDecision` in the dependencies it returns, `decision` in their `recovery`). Without them, a question inside a node that had not committed is asked again after a crash, and a second call can answer differently; see [Decision receipts](../../docs/host-integration.md#decision-receipts). An agent step is run again from its first turn after a crash, unless the host's node runner is the durable one (`nodeRunner`, from the same entry, with `durableNodes: true` in the `adapters` the host returns): then the step is one conversation the task owns, found again by its id, and a turn that committed is not requested again. Neither entry chooses this for you; it is the host's `runNode`. A tool call that was in flight at the crash has an unknown outcome and is never made again: the resume refuses it unless the host's `reconcile` settles it with a recorded result. A typed failure is a recorded outcome (`failed`, with a code), not a throw. A pause stops at a step boundary and the task stays pending until the host closes the Harness; reopening continues it. A cancel (pi's `abortTask`) ends the run for good, and a cancelled run never resumes.
268
+
263
269
  ## Sign in, pick a model, check keys
264
270
 
265
- `@parcha/agentrun-pi/signin` is what a demo, or your own app, runs before its first model call. The sample below is a sketch, not runnable as written: `spec` is a demo's spec (the `demo.json` of an example in the repository's `examples/` directory), and `provider`, `modelId` and `context` are your app's. It is the only entry of this package that loads `@earendil-works/pi-ai`, an optional peer: install it beside the package (`npm install @earendil-works/pi-ai`) to use this entry. No other entry imports it.
271
+ `@parcha/agentrun-pi/signin` is what a demo, or your own app, runs before its first model call. The sample below is a sketch, not runnable as written: `spec` is a demo's spec (the `demo.json` of an example in the repository's `examples/` directory), and `provider`, `modelId` and `context` are your app's. It is the only entry of this package that loads `@earendil-works/pi-ai`, an optional peer: install it beside the package (`npm install @earendil-works/pi-ai@1.3.0`) to use this entry. No other entry imports it.
266
272
 
267
273
  ```js
268
274
  import { builtinModels } from '@earendil-works/pi-ai/providers/all';
@@ -304,7 +310,7 @@ These commands create a separate `agentrun-pi-project` beside the checkout. You
304
310
 
305
311
  Pi is optional for `@parcha/agentrun-dsl`. This package depends on the DSL and the Jev adapter only. The Pi packages it uses (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-agent-core`, `@earendil-works/pi-tui`, and `@earendil-works/pi-ai` for the sign-in entry only) and `typebox` are optional peer dependencies at `*`, as Pi's package guidance requires, so an installed copy never shadows the host's. The release inventory records the locked versions.
306
312
 
307
- When loaded as an extension, Pi resolves its core packages and TypeBox through the host loader, and `pi install` adds nothing else. A standalone application or the `agentrun-pi` CLI has no host: install the peers yourself (`npm install @earendil-works/pi-coding-agent @earendil-works/pi-agent-core @earendil-works/pi-tui typebox`). This release supports Pi 1.0.4 to below 1.4, and CI tests 1.0.4, 1.1.0, 1.2.0 and 1.3.0; peer ranges at `*` do not make an older or newer host compatible. The sign-in entry needs pi-ai 1.1.0 or later: pi-ai 1.0.x ignores the app name and would put pi's name on ChatGPT's sign-in page, so `signIn` refuses it with `SignInError` (`APP_NAME_IGNORED`) before the page is shown. The offline loader regression checks competing local dependencies, a native file read, and the scripted workflow. Pi's [package guidance](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md#dependencies) is the rule this follows.
313
+ When loaded as an extension, Pi resolves its core packages and TypeBox through the host loader, and `pi install` adds nothing else. A standalone application or the `agentrun-pi` CLI has no host: install the peers yourself (`npm install @earendil-works/pi-coding-agent@1.3.0 @earendil-works/pi-agent-core@1.3.0 @earendil-works/pi-tui@1.3.0 typebox@1.3.7`). This release supports Pi 1.0.4 to below 1.4, and CI tests 1.0.4, 1.1.0, 1.2.0 and 1.3.0; peer ranges at `*` do not make an older or newer host compatible. The sign-in entry needs pi-ai 1.1.0 or later: pi-ai 1.0.x ignores the app name and would put pi's name on ChatGPT's sign-in page, so `signIn` refuses it with `SignInError` (`APP_NAME_IGNORED`) before the page is shown. The offline loader regression checks competing local dependencies, a native file read, and the scripted workflow. Pi's [package guidance](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md#dependencies) is the rule this follows.
308
314
 
309
315
  The durable entry (`@parcha/agentrun-pi/durable`) takes pi-durable and chord as optional peers from 1.0.4 to below 1.4; CI runs it on 1.0.4, 1.1.0, 1.2.0 and 1.3.0. A run that pi-durable 1.1 left (killed, paused, or with a tool call in flight) continues on 1.2. The move does not go back. A run that pi 1.2 left with a tool call in flight cannot be continued by pi 1.1: 1.1 waits on the newer task, and the only thing it writes is that task's mark from running back to pending, as pi does for any task a dead process left running. Continue such a run on 1.2, and do not downgrade pi under a live run. A host can call `refuseNewerTasks(harness, context)` from the durable entry after opening its Harness. On pi before 1.2, it then fails such a store with `NewerTaskError` (code `PI_TASK_NEWER`, naming the task's kind and version) instead of waiting.
310
316
 
@@ -4,7 +4,7 @@
4
4
  // Every run in the Session shares the rows' document, and a receipt's id does not name its run, so each row carries
5
5
  // the run's key and a run is answered only from its own rows.
6
6
  import { DECISION_RECEIPT_VERSION } from "@parcha/agentrun-dsl";
7
- import { appendDecision, DECISION_ROW_VERSION, readDecisions } from "./decisions.js";
7
+ import { appendDecision, DECISION_ROW_VERSION, readDecisionsFrom } from "./decisions.js";
8
8
  /** A receipt as the row the run `key` stores: the receipt's own fields, its identity, and the request's size. */
9
9
  function rowOf(receipt, exchange, key) {
10
10
  return {
@@ -41,7 +41,12 @@ export function decisionRecovery(invocation) {
41
41
  await runtime.commit(async (tx) => { result = await change(tx); return undefined; }, context);
42
42
  return result;
43
43
  },
44
+ // The form the store holds is settled once per invocation, from this reader: the run's open.
45
+ snapshot: ((...args) => runtime.snapshot(...args)),
44
46
  };
47
+ // The run's rows as read so far: a resumed process reads the store once, and each later answer reads only the rows after them.
48
+ const known = [];
49
+ let next = 0;
45
50
  let chain = Promise.resolve();
46
51
  return {
47
52
  recordDecision(receipt, exchange) {
@@ -51,7 +56,10 @@ export function decisionRecovery(invocation) {
51
56
  return committed;
52
57
  },
53
58
  async decision(id) {
54
- return answeredReceipt(await readDecisions(runtime, context), id, key);
59
+ const read = await readDecisionsFrom(harness, next, context);
60
+ known.push(...read.rows);
61
+ next = read.next;
62
+ return answeredReceipt(known, id, key);
55
63
  },
56
64
  };
57
65
  }
@@ -84,13 +84,30 @@ export type DecisionRow = {
84
84
  state_sha256?: string;
85
85
  run_key?: string;
86
86
  };
87
+ /** How many rows the keyed form holds: its next key. A few bytes, so rewriting it at every commit costs the same at every count. */
88
+ export declare const DECISION_COUNT_DOC = "agentrun.decisions.count";
89
+ /** A keyed row's key: its place, zero-padded so the keys sort in commit order. */
90
+ export declare const decisionKey: (place: number) => string;
91
+ export type DecisionForm = "single" | "keyed";
92
+ /** What a writer commits through: a Harness, or anything with its `commit` and `snapshot`. It must be able to read the store: the form is the store's, and a writer that could not look would have to guess. */
93
+ type Writer = Pick<Harness, "commit" | "snapshot">;
94
+ /** The form a reader's store holds: `single` when the old document is there, else `keyed`. Settled once per reader (the run's open): a
95
+ * run that began on the old document never switches mid-run. A reader with no way to read the store is refused, never guessed: a guess of `keyed` over an
96
+ * old store would write both forms into one recording, and a guess of `single` would write the old list into a fresh one. */
97
+ export declare function decisionForm(read: Partial<Pick<DocumentReader, "snapshot">>, context?: Context): Promise<DecisionForm>;
87
98
  /** Commit one row, appended after every row committed before it. */
88
- export declare function appendDecision(harness: Pick<Harness, "commit">, row: DecisionRow, context?: Context): Promise<void>;
99
+ export declare function appendDecision(harness: Writer, row: DecisionRow, context?: Context): Promise<void>;
89
100
  /** Every row the run's Session holds, in commit order. */
90
101
  export declare function readDecisions(read: DocumentReader, context?: Context): Promise<DecisionRow[]>;
102
+ /** The rows from place `from` on and the place after the last: for a keyed store only the new ones are read, so a reader that
103
+ * keeps `next` reads each row once. The old document is read whole (its list is the rows; `next` is its length). */
104
+ export declare function readDecisionsFrom(read: Pick<DocumentReader, "snapshot">, from: number, context?: Context): Promise<{
105
+ rows: DecisionRow[];
106
+ next: number;
107
+ }>;
91
108
  /** Where a writer commits: the run's Session, opened when the first row needs it and released by `flush`. */
92
109
  export type DecisionSession = {
93
- harness: Pick<Harness, "commit">;
110
+ harness: Writer;
94
111
  release(): Promise<void>;
95
112
  };
96
113
  export type DecisionWriter = {
@@ -111,3 +128,4 @@ export declare function createDecisionWriter(options: {
111
128
  open: () => Promise<DecisionSession>;
112
129
  context?: Context;
113
130
  }): DecisionWriter;
131
+ export {};
@@ -1,24 +1,76 @@
1
- // A run's decision rows as pi-durable stores them: the session document `agentrun.decisions`, one row per typed
2
- // question the run asked, in commit order. The row is a stored format (version 1) and does not change with the
1
+ // A run's decision rows as pi-durable stores them, one row per typed question the run asked, in commit order. Two
2
+ // forms, one per store: a run on a fresh store writes the KEYED family (below), one document of kind `agentrun.decisions`
3
+ // per row, written once; a store that already holds the OLD single session document `agentrun.decisions` (its
4
+ // `receipts` list, rewritten at every commit) keeps writing it to the end of its run, so a recording never holds both.
5
+ // The row is a stored format (version 1) and is the same in both forms, byte for byte; it does not change with the
3
6
  // interpreter: a host maps `@parcha/agentrun-dsl`'s `DecisionReceipt` to a row, with the facts only the host has (the
4
7
  // request's size and metering, whether the node acted on the answers), and this module stores and reads rows without
5
8
  // looking inside them.
6
9
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
7
- import { defineDoc } from "@earendil-works/pi-durable";
10
+ import { defineDoc, defineDocFamily } from "@earendil-works/pi-durable";
8
11
  /** The session document that holds a run's decision rows. */
9
12
  export const DECISION_RECEIPTS_DOC = "agentrun.decisions";
10
13
  /** The stored row's version. */
11
14
  export const DECISION_ROW_VERSION = 1;
15
+ /** The old form: one session document whose `receipts` list is rewritten at every commit. Read, and written only in a store that holds it. */
12
16
  const DecisionsDoc = defineDoc({ kind: DECISION_RECEIPTS_DOC, version: 1, scope: "session", initial: () => ({ receipts: [] }) });
17
+ /** The keyed form: one document per row, of the same kind, keyed by the row's place in the run's rows (`keyOf`), written once. The
18
+ * document's value is exactly the row. */
19
+ const DecisionRowDoc = defineDocFamily({ kind: DECISION_RECEIPTS_DOC, version: 1, scope: "session", family: true, initial: () => ({}) });
20
+ /** How many rows the keyed form holds: its next key. A few bytes, so rewriting it at every commit costs the same at every count. */
21
+ export const DECISION_COUNT_DOC = "agentrun.decisions.count";
22
+ const DecisionCountDoc = defineDoc({ kind: DECISION_COUNT_DOC, version: 1, scope: "session", initial: () => ({ count: 0 }) });
23
+ /** A keyed row's key: its place, zero-padded so the keys sort in commit order. */
24
+ export const decisionKey = (place) => String(place).padStart(8, "0");
25
+ const forms = new WeakMap();
26
+ /** The form a reader's store holds: `single` when the old document is there, else `keyed`. Settled once per reader (the run's open): a
27
+ * run that began on the old document never switches mid-run. A reader with no way to read the store is refused, never guessed: a guess of `keyed` over an
28
+ * old store would write both forms into one recording, and a guess of `single` would write the old list into a fresh one. */
29
+ export function decisionForm(read, context = BACKGROUND_CONTEXT) {
30
+ if (typeof read.snapshot !== "function")
31
+ return Promise.reject(new Error("agentrun.decisions: the writer cannot read the store, so it cannot tell which form the store holds; give it a Harness, or an object with commit and snapshot"));
32
+ let known = forms.get(read);
33
+ if (!known) {
34
+ known = read.snapshot(DecisionsDoc, context).then((old) => (old === undefined ? "keyed" : "single"));
35
+ forms.set(read, known);
36
+ known.catch(() => forms.delete(read));
37
+ }
38
+ return known;
39
+ }
13
40
  /** Commit one row, appended after every row committed before it. */
14
41
  export async function appendDecision(harness, row, context = BACKGROUND_CONTEXT) {
15
42
  const stored = JSON.parse(JSON.stringify(row));
16
- await harness.commit(async (tx) => { (await tx.doc(DecisionsDoc)).receipts.push(stored); }, context);
43
+ if ((await decisionForm(harness, context)) === "single") {
44
+ await harness.commit(async (tx) => { (await tx.doc(DecisionsDoc)).receipts.push(stored); }, context);
45
+ return;
46
+ }
47
+ await harness.commit(async (tx) => {
48
+ const count = await tx.doc(DecisionCountDoc);
49
+ const place = count.count;
50
+ count.count = place + 1;
51
+ Object.assign(await tx.doc(DecisionRowDoc, decisionKey(place), null), stored);
52
+ }, context);
17
53
  }
18
54
  /** Every row the run's Session holds, in commit order. */
19
55
  export async function readDecisions(read, context = BACKGROUND_CONTEXT) {
20
- const doc = await read.snapshot(DecisionsDoc, context);
21
- return (doc?.receipts ?? []);
56
+ return (await readDecisionsFrom(read, 0, context)).rows;
57
+ }
58
+ /** The rows from place `from` on and the place after the last: for a keyed store only the new ones are read, so a reader that
59
+ * keeps `next` reads each row once. The old document is read whole (its list is the rows; `next` is its length). */
60
+ export async function readDecisionsFrom(read, from, context = BACKGROUND_CONTEXT) {
61
+ if ((await decisionForm(read, context)) === "single") {
62
+ const rows = ((await read.snapshot(DecisionsDoc, context))?.receipts ?? []);
63
+ return { rows: rows.slice(from), next: rows.length };
64
+ }
65
+ const total = (await read.snapshot(DecisionCountDoc, context))?.count ?? 0;
66
+ const rows = [];
67
+ for (let at = from; at < total; at += 64) {
68
+ const batch = await Promise.all(Array.from({ length: Math.min(64, total - at) }, (_, i) => read.snapshot(DecisionRowDoc, decisionKey(at + i), context)));
69
+ for (const doc of batch)
70
+ if (doc !== undefined)
71
+ rows.push(doc);
72
+ }
73
+ return { rows, next: total };
22
74
  }
23
75
  /** A writer that commits rows in the order they are recorded. `open` runs once, when the first row is committed, so
24
76
  * a run that records no row never opens its Session here. */
@@ -1,10 +1,11 @@
1
1
  export type { RecoveryBinding, RecoveryEffect, RecoveryJournal, RecoveryNote, RecoveryStore } from "@parcha/agentrun-dsl/recovery";
2
2
  export { documentStore, type DocumentStoreHost, type DocumentStoreOptions } from "./store.js";
3
- export { committedRoutes, durableDoc, readJournal, type JournalView } from "./readers.js";
3
+ export { committedRoutes, durableDecisions, durableDoc, readJournal, type DurableReadOptions, type JournalView } from "./readers.js";
4
4
  export { recordCommits, recordingLines, snapshotLines, type RecordingLine, type RecordingSource } from "./recording.js";
5
5
  export { reuseReceipts, seedReceipts, ReceiptIndex, type ReceiptReuse, type ReuseReceiptsOptions, type ReusableTool } from "./receipts.js";
6
+ export { effectResultOf } from "../effect-result.js";
6
7
  export { NewerTaskError, refuseNewerTasks, toolResult, ToolResultKeyError, type TextBlock } from "./pi-versions.js";
7
- export { DECISION_RECEIPTS_DOC, DECISION_ROW_VERSION, appendDecision, createDecisionWriter, readDecisions } from "./decisions.js";
8
+ export { DECISION_COUNT_DOC, DECISION_RECEIPTS_DOC, DECISION_ROW_VERSION, appendDecision, createDecisionWriter, decisionKey, readDecisions } from "./decisions.js";
8
9
  export type { DecisionRow, DecisionRowRule, DecisionSession, DecisionWriter } from "./decisions.js";
9
10
  export { decisionRecovery, type DecisionRecovery } from "./decision-recovery.js";
10
11
  export { DELIVERY_ATTEMPTS, deliver, deliveryEnded, deliveryTerminates, deliveryText, disagreementText, extrasNotInRecord, fileSubmission, gate, healNullSpellings, lintRecordRoot, lintRecordSchema, mixedFileSubmission, nudgeText, nullSpellings, parseStringifiedContainers, repairRecord, spend, submitFooter, VIOLATIONS_SHOWN, } from "./record.js";
@@ -1,9 +1,10 @@
1
1
  export { documentStore } from "./store.js";
2
- export { committedRoutes, durableDoc, readJournal } from "./readers.js";
2
+ export { committedRoutes, durableDecisions, durableDoc, readJournal } from "./readers.js";
3
3
  export { recordCommits, recordingLines, snapshotLines } from "./recording.js";
4
4
  export { reuseReceipts, seedReceipts, ReceiptIndex } from "./receipts.js";
5
+ export { effectResultOf } from "../effect-result.js";
5
6
  export { NewerTaskError, refuseNewerTasks, toolResult, ToolResultKeyError } from "./pi-versions.js";
6
- export { DECISION_RECEIPTS_DOC, DECISION_ROW_VERSION, appendDecision, createDecisionWriter, readDecisions } from "./decisions.js";
7
+ export { DECISION_COUNT_DOC, DECISION_RECEIPTS_DOC, DECISION_ROW_VERSION, appendDecision, createDecisionWriter, decisionKey, readDecisions } from "./decisions.js";
7
8
  export { decisionRecovery } from "./decision-recovery.js";
8
9
  export { DELIVERY_ATTEMPTS, deliver, deliveryEnded, deliveryTerminates, deliveryText, disagreementText, extrasNotInRecord, fileSubmission, gate, healNullSpellings, lintRecordRoot, lintRecordSchema, mixedFileSubmission, nudgeText, nullSpellings, parseStringifiedContainers, repairRecord, spend, submitFooter, VIOLATIONS_SHOWN, } from "./record.js";
9
10
  export { workspaceRecordFile } from "./record-file.js";
@@ -5,12 +5,18 @@ import { type RecoveryNodeParams } from "@parcha/agentrun-dsl/recovery";
5
5
  import { type NodeScope } from "./node.js";
6
6
  import { type Disagreement, type RecordContract } from "./record.js";
7
7
  import { type RecordToolOptions } from "./record-tool.js";
8
- /** A node's configuration: what its conversation's prompt and `submit` tool are drawn from. */
8
+ /** A node's configuration: what its conversation's prompt and `submit` tool are drawn from. `input` is the node's
9
+ * prompt state, the JSON object its user text was stringified from, for a reader that shows what the node was given;
10
+ * it is absent when that text is not a JSON object, and in a document an older build wrote. A fork of the conversation
11
+ * starts without it, as with the record: the runner never attaches a fork, so a fork is no node and waits for nothing. */
9
12
  export declare const NodeDoc: import("@earendil-works/pi-durable").ConversationDocToken<{
10
13
  system: string;
11
14
  label: string;
12
15
  schema: JsonValue | null;
13
16
  fileKey: string | null;
17
+ input?: {
18
+ [key: string]: JsonValue;
19
+ };
14
20
  }>;
15
21
  /** A node as the host is asked about it: the interpreter's request, and the attempt it runs as, with the sessions of
16
22
  * the attempts before it (a host that refuses to repeat a call an earlier attempt left unknown reads them). */
@@ -12,15 +12,30 @@
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { configure, defineDoc, defineExtension, GenerationTask, hook, section, } from "@earendil-works/pi-durable";
14
14
  import { canonicalSha256, runStopOf } from "@parcha/agentrun-dsl/recovery";
15
+ import { SUBMIT_TOOL } from "../host-tools.js";
15
16
  import { nodeThinking } from "../thinking.js";
16
17
  import { closeNodeAttempt, runNodeAttempt } from "./node.js";
17
18
  import { DELIVERY_ATTEMPTS, lintRecordRoot, submitFooter } from "./record.js";
18
19
  import { workspaceRecordFile } from "./record-file.js";
19
20
  import { RecordDoc, recordNudge, recordTool, spendNudge, submitDefinition } from "./record-tool.js";
20
- /** A node's configuration: what its conversation's prompt and `submit` tool are drawn from. */
21
+ /** A node's configuration: what its conversation's prompt and `submit` tool are drawn from. `input` is the node's
22
+ * prompt state, the JSON object its user text was stringified from, for a reader that shows what the node was given;
23
+ * it is absent when that text is not a JSON object, and in a document an older build wrote. A fork of the conversation
24
+ * starts without it, as with the record: the runner never attaches a fork, so a fork is no node and waits for nothing. */
21
25
  export const NodeDoc = defineDoc({
22
- kind: "agentrun.node", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ system: "", label: "", schema: null, fileKey: null }),
26
+ kind: "agentrun.node", version: 1, scope: "conversation", history: "latest", fork: "initial", initial: () => ({ system: "", label: "", schema: null, fileKey: null }),
23
27
  });
28
+ /** The JSON object a node's user text holds, or nothing: the interpreter sends a node its prompt state stringified. */
29
+ function inputOf(user) {
30
+ let value;
31
+ try {
32
+ value = JSON.parse(user);
33
+ }
34
+ catch {
35
+ return {};
36
+ }
37
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? { input: value } : {};
38
+ }
24
39
  /** A node that delivered no record. `final` says a second attempt would spend the same again: the model did not
25
40
  * answer, or the delivery attempts are spent. `unanswered` is pi's own account of a submission the model did not
26
41
  * answer: its reason (`failed`, `aborted`, ...) and detail. */
@@ -64,7 +79,7 @@ async function underStop(signal, work) {
64
79
  }
65
80
  }
66
81
  const listed = (value) => Array.isArray(value);
67
- const isSubmit = (tool) => tool.name === "submit";
82
+ const isSubmit = (tool) => tool.name === SUBMIT_TOOL;
68
83
  /** The node runner over a host. Install `extension` in the registry before the Harness opens; `on` binds the runner
69
84
  * to where its conversations live (`taskScope`, `hostScope`). */
70
85
  export function nodeRunner(host, options = {}) {
@@ -163,8 +178,9 @@ export function nodeRunner(host, options = {}) {
163
178
  const { agent, record } = await underStop(signal, async () => ({ agent: leveled(await host.agent(node), node), record: await host.record?.(node) ?? {} }));
164
179
  const label = record.label ?? `the ${node.label} record`;
165
180
  const offered = (listed(agent.tools) ? agent.tools : listed(agent.extensions) ? agent.extensions.flatMap((selected) => selected.tools ?? []) : [])
166
- .map((offer) => offer.name).filter((name) => name !== "submit");
181
+ .map((offer) => offer.name).filter((name) => name !== SUBMIT_TOOL);
167
182
  const system = [...node.system, host.stance?.(node, { tools: offered })].filter(Boolean).join("\n\n");
183
+ const input = inputOf(node.user);
168
184
  const reviewers = [
169
185
  ...(host.verify ? [(candidate, at) => host.verify(candidate, node, at)] : []),
170
186
  ...(review ? [async (candidate) => {
@@ -183,14 +199,14 @@ export function nodeRunner(host, options = {}) {
183
199
  agent: withNodes(agent),
184
200
  user: `${node.user}\n${submitFooter({ label, schema: node.schema, reviewed: reviewers.length > 0, ...(record.file ? { fileKey: record.file.key } : {}), ...(record.instructions ? { instructions: record.instructions } : {}) })}`,
185
201
  init: async (tx, id) => {
186
- Object.assign(await tx.doc(NodeDoc, id), { system, label, schema: node.schema, fileKey: record.file?.key ?? null });
202
+ Object.assign(await tx.doc(NodeDoc, id), { system, label, schema: node.schema, fileKey: record.file?.key ?? null, ...input });
187
203
  await host.init?.(node, tx, id);
188
204
  },
189
205
  adopt: async (tx, id, stored) => {
190
206
  const held = await tx.doc(NodeDoc, id);
191
207
  if (held.schema !== null)
192
208
  return;
193
- Object.assign(held, { system: typeof stored.system === "string" ? stored.system : system, label, schema: node.schema, fileKey: record.file?.key ?? null });
209
+ Object.assign(held, { system: typeof stored.system === "string" ? stored.system : system, label, schema: node.schema, fileKey: record.file?.key ?? null, ...input });
194
210
  // The older build selected an extension of its own for this conversation, which is not installed here.
195
211
  const own = typeof stored.extension === "string" ? [{ name: stored.extension }] : [];
196
212
  const given = withNodes(agent);
@@ -11,17 +11,26 @@ export type JournalView = {
11
11
  notes: RecoveryNote[];
12
12
  effects: RecoveryEffect[];
13
13
  };
14
+ /** How a reader opens the store file. `immutable` opens it as SQLite's immutable file, for a store no process writes any
15
+ * more and with no write-ahead log beside it (an ended run's): nothing beside it is read or made. */
16
+ export type DurableReadOptions = {
17
+ immutable?: boolean;
18
+ };
14
19
  /** The run's store `file`, opened read-only for `read`; null when there is no such file. Every query of one `read` runs
15
20
  * in one read transaction, so what it returns is one commit's view, whatever a writer commits meanwhile. */
16
- export declare function withDurableFile<T>(file: string, read: (db: Database) => T): T | null;
21
+ export declare function withDurableFile<T>(file: string, read: (db: Database) => T, options?: DurableReadOptions): T | null;
17
22
  /** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
18
- export declare function durableDoc<T = Record<string, unknown>>(file: string, kind: string, key?: string): T | undefined;
23
+ export declare function durableDoc<T = Record<string, unknown>>(file: string, kind: string, key?: string, options?: DurableReadOptions): T | undefined;
24
+ /** The decision rows (`agentrun.decisions`) of the run's store `file`, in commit order, in whichever form the store holds: the single
25
+ * document's `receipts` list, or the keyed documents (one row each, in the order of their keys). A reader that folds decisions from the
26
+ * store uses this and not `durableDoc(file, "agentrun.decisions").receipts`, which is nothing in the keyed form. */
27
+ export declare function durableDecisions(file: string): Array<Record<string, unknown>>;
19
28
  /** The journal `key` holds in the run's store `file`, as the last commit left it; undefined when the file holds no
20
29
  * such journal or none has bound it. `effects` are every effect the journal admitted, once each, in the order they were
21
30
  * admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
22
31
  * included, are listed by that item's record, not the driver's, and are read from there. `state` is the driver's own
23
32
  * record: in the keyed layout what lies in each map item stays in the item's record. */
24
- export declare function readJournal(file: string, key: string): JournalView | undefined;
33
+ export declare function readJournal(file: string, key: string, options?: DurableReadOptions): JournalView | undefined;
25
34
  /** Every committed route decision a frozen snapshot holds (each judge's answer, whole), by execution path. Empty for a
26
35
  * state that is no snapshot. */
27
36
  export declare function committedRoutes(state: unknown): Record<string, Json>;
@@ -4,15 +4,17 @@
4
4
  // readers take the store file itself (pi-durable's SQLite file, wherever the host keeps it), never a directory.
5
5
  import fs from "node:fs";
6
6
  import { createRequire } from "node:module";
7
+ import { pathToFileURL } from "node:url";
7
8
  import { apply } from "@earendil-works/chord/delta";
9
+ import { decisionKey } from "./decisions.js";
8
10
  import { effectKey } from "./store.js";
9
11
  /** The run's store `file`, opened read-only for `read`; null when there is no such file. Every query of one `read` runs
10
12
  * in one read transaction, so what it returns is one commit's view, whatever a writer commits meanwhile. */
11
- export function withDurableFile(file, read) {
13
+ export function withDurableFile(file, read, options = {}) {
12
14
  if (!fs.existsSync(file))
13
15
  return null;
14
16
  const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
15
- const db = new DatabaseSync(file, { readOnly: true, timeout: 5000 });
17
+ const db = new DatabaseSync(options.immutable ? `${pathToFileURL(file).href}?immutable=1` : file, { readOnly: true, timeout: 5000 });
16
18
  try {
17
19
  db.exec("BEGIN");
18
20
  return read(db);
@@ -66,15 +68,34 @@ function itemEffects(db, key, table, into) {
66
68
  }
67
69
  }
68
70
  /** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
69
- export function durableDoc(file, kind, key) {
70
- return (withDurableFile(file, (db) => sessionDoc(db, kind, key)) ?? undefined);
71
+ export function durableDoc(file, kind, key, options = {}) {
72
+ return (withDurableFile(file, (db) => sessionDoc(db, kind, key), options) ?? undefined);
73
+ }
74
+ /** The decision rows (`agentrun.decisions`) of the run's store `file`, in commit order, in whichever form the store holds: the single
75
+ * document's `receipts` list, or the keyed documents (one row each, in the order of their keys). A reader that folds decisions from the
76
+ * store uses this and not `durableDoc(file, "agentrun.decisions").receipts`, which is nothing in the keyed form. */
77
+ export function durableDecisions(file) {
78
+ return withDurableFile(file, (db) => {
79
+ const single = sessionDoc(db, "agentrun.decisions");
80
+ if (single)
81
+ return Array.isArray(single.receipts) ? single.receipts : [];
82
+ const count = sessionDoc(db, "agentrun.decisions.count")?.count;
83
+ const rows = [];
84
+ if (typeof count === "number")
85
+ for (let place = 0; place < count; place += 1) {
86
+ const row = sessionDoc(db, "agentrun.decisions", decisionKey(place));
87
+ if (row)
88
+ rows.push(row);
89
+ }
90
+ return rows;
91
+ }) ?? [];
71
92
  }
72
93
  /** The journal `key` holds in the run's store `file`, as the last commit left it; undefined when the file holds no
73
94
  * such journal or none has bound it. `effects` are every effect the journal admitted, once each, in the order they were
74
95
  * admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
75
96
  * included, are listed by that item's record, not the driver's, and are read from there. `state` is the driver's own
76
97
  * record: in the keyed layout what lies in each map item stays in the item's record. */
77
- export function readJournal(file, key) {
98
+ export function readJournal(file, key, options = {}) {
78
99
  return withDurableFile(file, (db) => {
79
100
  const doc = sessionDoc(db, "agentrun.driver", key);
80
101
  if (!doc || doc.binding === null)
@@ -93,7 +114,7 @@ export function readJournal(file, key) {
93
114
  return { id, name: e?.name ?? "", argsHash: e?.argsHash ?? "", status: e?.status ?? "unknown", session: e?.session ?? null, result: e?.result ?? null, ...(e?.intent ? { intent: e.intent } : {}), ...(e?.args !== undefined ? { args: e.args } : {}), ...(Array.isArray(e?.calls) ? { calls: e.calls } : {}) };
94
115
  });
95
116
  return { binding: doc.binding, inputs: doc.inputs, generation: doc.generation, revision: doc.revision, state: doc.state, notes: doc.notes, effects };
96
- }) ?? undefined;
117
+ }, options) ?? undefined;
97
118
  }
98
119
  /** Every committed route decision a frozen snapshot holds (each judge's answer, whole), by execution path. Empty for a
99
120
  * state that is no snapshot. */
@@ -32,7 +32,10 @@ export type ReuseReceiptsOptions = {
32
32
  /** Whether a repeat of another session's paid call is answered from its receipt (a continuation's sessions do). Receipts
33
33
  * are recorded either way. */
34
34
  answer: boolean;
35
- /** Effects whose outcome is unknown, from the handoff (`handoff.unknown`): a call equal to one is refused. */
35
+ /** Effects whose outcome is unknown, from the handoff (`handoff.unknown`): a call equal to one is refused, with a tool
36
+ * result whose `details.effect_status` is "unknown". A workflow call that runs a wrapped tool passes its result through
37
+ * `effectResultOf`, which turns that refusal into the DSL's thrown signal. A durable task runs the host's own `runEffect`
38
+ * (`WorkflowHostParts`), so a durable host calls it there; without it the refusal reads as the call's result. */
36
39
  unknown?: readonly Pick<InheritedUnknown, "name" | "argsHash" | "intent">[];
37
40
  /** How a call's key is derived. Default: the gateway tool a `fetch` wrapper names, else the tool's own name. */
38
41
  keyOf?: (toolName: string, args: unknown) => {
@@ -1,5 +1,6 @@
1
1
  import { defineDoc, defineTool, GenerationTask, hook, } from "@earendil-works/pi-durable";
2
2
  import { DELIVERY_ATTEMPTS, deliver, deliveryTerminates, deliveryText, fileSubmission, mixedFileSubmission, nudgeText, repairRecord, VIOLATIONS_SHOWN, } from "./record.js";
3
+ import { SUBMIT_TOOL } from "../host-tools.js";
3
4
  import { toolResult } from "./pi-versions.js";
4
5
  /** The record a conversation delivered, or null while it has not, and the delivery attempts it has spent. */
5
6
  export const RecordDoc = defineDoc({
@@ -76,7 +77,7 @@ export function recordTool(options) {
76
77
  ...(deliveryTerminates(delivery) ? { control: { terminate: true } } : {}),
77
78
  });
78
79
  return defineTool({
79
- name: "submit",
80
+ name: SUBMIT_TOOL,
80
81
  description: shown.description,
81
82
  parameters: shown.parameters,
82
83
  // Every call's arguments are carried to `execute`; one record's are repaired here, before the Harness validates.
@@ -54,6 +54,11 @@ function documentLine(id, kind, key, value, version, conv, commit) {
54
54
  data: { doc: kind, ...(key !== undefined ? { key } : {}), value, version },
55
55
  } };
56
56
  }
57
+ const conversationLine = (record, commit) => ({ commit, rank: -1, id: Number(record.id), line: { channel: "durable", source: "pi", kind: "pi.conversation", commit, conv: Number(record.id), data: {
58
+ id: Number(record.id),
59
+ ...(record.parent ? { parent: { conversationId: Number(record.parent.conversationId), at: Number(record.parent.at) } } : {}),
60
+ ...(record.owner ? { owner: { conversationId: Number(record.owner.conversationId), taskId: Number(record.owner.taskId) } } : {}),
61
+ } } });
57
62
  const entryLine = (entry, commit) => ({ commit, rank: 1, id: Number(entry.id), line: {
58
63
  channel: "durable", source: "pi", kind: String(entry?.kind ?? "pi.entry"), commit, conv: Number(entry.conversationId), data: entry,
59
64
  } });
@@ -74,14 +79,23 @@ function numbered(placed, genAt, extra = {}) {
74
79
  }
75
80
  return lines;
76
81
  }
82
+ /** A document's revisions in commit order, each with the document's whole value after it: a base replaces the value,
83
+ * a delta is applied to a copy of it. */
84
+ function* replayed(revisions) {
85
+ let value;
86
+ for (const rev of revisions) {
87
+ value = rev.kind === "base" ? JSON.parse(rev.content) : apply(structuredClone(value), JSON.parse(rev.content));
88
+ yield [rev, value];
89
+ }
90
+ }
77
91
  /** Each node attempt's conversation and path, from every `agentrun.nodes` document's latest value. */
78
92
  function nodeConversations(db) {
79
93
  const out = new Map();
80
94
  for (const doc of db.prepare("SELECT id, key_value FROM documents WHERE kind = ? AND family = 1").all(JSON.stringify("agentrun.nodes"))) {
95
+ // Only the latest value is kept: a document rewritten whole at each commit has as many values as revisions.
81
96
  let value;
82
- for (const rev of db.prepare("SELECT kind, content FROM document_revisions WHERE document_id = ? ORDER BY seq").all(doc.id)) {
83
- value = rev.kind === "base" ? JSON.parse(rev.content) : apply(structuredClone(value), JSON.parse(rev.content));
84
- }
97
+ for (const [, next] of replayed(db.prepare("SELECT kind, content FROM document_revisions WHERE document_id = ? ORDER BY seq").all(doc.id)))
98
+ value = next;
85
99
  const found = nodeConversation(JSON.parse(doc.key_value), value);
86
100
  if (found)
87
101
  out.set(found[0], found[1]);
@@ -92,9 +106,7 @@ function nodeConversations(db) {
92
106
  function driverGenerations(db) {
93
107
  const out = [];
94
108
  for (const { id } of db.prepare("SELECT id FROM documents WHERE kind = ?").all(JSON.stringify("agentrun.driver"))) {
95
- let value;
96
- for (const rev of db.prepare("SELECT seq, kind, content FROM document_revisions WHERE document_id = ? ORDER BY seq").all(id)) {
97
- value = rev.kind === "base" ? JSON.parse(rev.content) : apply(structuredClone(value), JSON.parse(rev.content));
109
+ for (const [rev, value] of replayed(db.prepare("SELECT seq, kind, content FROM document_revisions WHERE document_id = ? ORDER BY seq").all(id))) {
98
110
  if (typeof value?.generation === "number")
99
111
  out.push([rev.seq, value.generation]);
100
112
  }
@@ -132,13 +144,17 @@ export function recordingLines(file, options = {}) {
132
144
  const kind = JSON.parse(doc.kind);
133
145
  const key = doc.family === 1 ? JSON.parse(doc.key_value) : undefined;
134
146
  const conv = doc.scope_kind === "conversation" ? doc.owner_id : doc.scope_kind === "task" ? taskConversation.get(doc.owner_id) : undefined;
135
- let value;
136
- for (const rev of db.prepare("SELECT seq, kind, version, content FROM document_revisions WHERE document_id = ? ORDER BY seq").all(doc.id)) {
137
- value = rev.kind === "base" ? JSON.parse(rev.content) : apply(structuredClone(value), JSON.parse(rev.content));
147
+ for (const [rev, value] of replayed(db.prepare("SELECT seq, kind, version, content FROM document_revisions WHERE document_id = ? ORDER BY seq").all(doc.id))) {
138
148
  if (rev.seq > after)
139
149
  placed.push(documentLine(doc.id, kind, key, structuredClone(value), rev.version, conv, rev.seq));
140
150
  }
141
151
  }
152
+ // A conversation's row carries no commit of its own: it is read at the first commit that wrote anything of it (an entry, or a revision of one of its documents, which pi writes in the commit that creates it), and one with neither yet at the store's last commit, the latest it can have been made.
153
+ const last = db.prepare("SELECT next_seq FROM durable_metadata WHERE singleton = 1").get().next_seq - 1;
154
+ const seen = "SELECT conversation_id AS cid, commit_seq AS seq FROM entries UNION ALL SELECT d.owner_id AS cid, r.seq AS seq FROM documents d JOIN document_revisions r ON r.document_id = d.id WHERE d.scope_kind = 'conversation'";
155
+ for (const row of db.prepare(`SELECT c.record AS record, COALESCE(MIN(x.seq), ?) AS commit_seq FROM conversations c LEFT JOIN (${seen}) x ON x.cid = c.id GROUP BY c.id HAVING COALESCE(MIN(x.seq), ?) > ?`).all(last, last, after)) {
156
+ placed.push(conversationLine(JSON.parse(row.record), row.commit_seq));
157
+ }
142
158
  for (const entry of db.prepare("SELECT commit_seq, record FROM entries WHERE commit_seq > ?").all(after)) {
143
159
  placed.push(entryLine(JSON.parse(entry.record), entry.commit_seq));
144
160
  }
@@ -181,7 +197,9 @@ function publicationLines(publication, state) {
181
197
  placed.push(entryLine(structuredClone(change.value), commit));
182
198
  else if (change.type === "task")
183
199
  placed.push(taskLine(structuredClone(change.value), commit));
184
- // A conversation's or a submission's record, and a document copy (it carries no value), make no line.
200
+ else if (change.type === "conversation")
201
+ placed.push(conversationLine(change.value, commit));
202
+ // A submission's record, and a document copy (it carries no value), make no line.
185
203
  }
186
204
  if (written !== undefined)
187
205
  state.gen = written;
@@ -267,7 +285,7 @@ export async function snapshotLines(storage, options) {
267
285
  ...conversations.map((conversation) => ({ scope: { kind: "conversation", conversationId: conversation.id }, conv: Number(conversation.id) })),
268
286
  ...tasks.map((task) => ({ scope: { kind: "task", taskId: task.id }, conv: Number(task.conversationId) })),
269
287
  ];
270
- const placed = [];
288
+ const placed = conversations.map((conversation) => conversationLine(conversation, at));
271
289
  let driverGen;
272
290
  for (const { scope, conv } of scopes) {
273
291
  for (const record of await scanAll((cursor) => storage.scanDocuments({ scope, at: "current" }, page, cursor, context))) {
@@ -8,6 +8,7 @@ import { Type } from "typebox";
8
8
  import { inspectWorkflow, WORKFLOW_NODE_KINDS } from "@parcha/agentrun-dsl";
9
9
  import { canonicalHash } from "@parcha/agentrun-dsl/recovery";
10
10
  import { extensionStructuralLimits, WorkflowExtensionService } from "../extension-service.js";
11
+ import { assertHostTools, SUBMIT_TOOL } from "../host-tools.js";
11
12
  import { modelJson } from "../presentation.js";
12
13
  import { WorkflowStore } from "../workflow-store.js";
13
14
  import { progressLines, WorkflowObservation } from "../workflow-view.js";
@@ -36,6 +37,9 @@ const answer = (value) => toolResult([{ type: "text", text: modelJson(value) }],
36
37
  /** The tool, and the workflow task its runs are: install `extension` (it carries both) and list the tool's name in the
37
38
  * agent definition. `workflow` reads and stops runs from the host's side. */
38
39
  export function createWorkflowTool(host) {
40
+ // The host's tools pass the check the coding-agent extension's do; the tool's own name and the node runner's submit are reserved.
41
+ const name = host.name ?? "agentrun";
42
+ assertHostTools(host.tools ?? [], { reserved: [name, SUBMIT_TOOL], executable: false });
39
43
  const names = (host.tools ?? []).map((tool) => tool.name);
40
44
  const checker = () => new WorkflowExtensionService({ allowedTools: names });
41
45
  /** Inspect's checks, after naming any tool the workflow uses (a call's, or an agent node's) that the host did not
@@ -83,7 +87,7 @@ export function createWorkflowTool(host) {
83
87
  },
84
88
  });
85
89
  const tool = defineTool({
86
- name: host.name ?? "agentrun",
90
+ name,
87
91
  description: "Compose and run AgentRun workflows in this session. Describe lists the tools a workflow may use, as a call's tool or an agent node's tools, and the language; a workflow uses only the tools describe lists, which the host registers. Inspect validates a complete workflow and keeps it as this conversation's draft. Dry-run checks the draft on synthetic values (or the given input) with no model, Jev or tool call. Run starts the draft as a durable task of this conversation and answers with its outcome. Running the same draft on the same input again, after a restart too, reaches the same run and makes no effect twice; again: true starts a fresh run of them, which a later run then reaches. Save, load and list keep named definition revisions.",
88
92
  parameters: Type.Object({ ...workflowToolProperties(), again: Type.Optional(Type.Boolean({ description: "Run only: start a fresh run of the same draft and input instead of reaching the one already run." })) }),
89
93
  executionMode: "sequential",
@@ -0,0 +1,7 @@
1
+ /** A tool's result at a workflow call's effect boundary. A call `reuseReceipts` refused (an identical earlier call was cut
2
+ * while it ran) is an effect whose outcome is unknown, so it becomes the DSL's one signal for that: a thrown
3
+ * `RecoveryError` with code `FROZEN_EFFECT_UNKNOWN`, the refusal its cause. A refusal is keyed by the exact shape only
4
+ * `reuseReceipts` writes — `{ isError: true, details: { effect_status: "unknown", refused: true } }` — not by
5
+ * `effect_status: "unknown"` alone, which an ordinary tool (an inconclusive classifier) may carry in its own answer.
6
+ * Any other result is the effect's result, returned as it is. */
7
+ export declare function effectResultOf<T>(result: T, label: string): T;
@@ -0,0 +1,17 @@
1
+ // The boundary where a pi tool's result becomes a workflow call's effect result. It imports only the DSL, so the plain
2
+ // extension and the durable host both use it.
3
+ import { RecoveryError } from "@parcha/agentrun-dsl/recovery";
4
+ /** A tool's result at a workflow call's effect boundary. A call `reuseReceipts` refused (an identical earlier call was cut
5
+ * while it ran) is an effect whose outcome is unknown, so it becomes the DSL's one signal for that: a thrown
6
+ * `RecoveryError` with code `FROZEN_EFFECT_UNKNOWN`, the refusal its cause. A refusal is keyed by the exact shape only
7
+ * `reuseReceipts` writes — `{ isError: true, details: { effect_status: "unknown", refused: true } }` — not by
8
+ * `effect_status: "unknown"` alone, which an ordinary tool (an inconclusive classifier) may carry in its own answer.
9
+ * Any other result is the effect's result, returned as it is. */
10
+ export function effectResultOf(result, label) {
11
+ const row = result;
12
+ const details = row?.details;
13
+ if (row?.isError === true && details?.effect_status === "unknown" && details?.refused === true) {
14
+ throw new RecoveryError(`${label}: an identical earlier call was cut while it ran, so whether it happened is unknown`, "FROZEN_EFFECT_UNKNOWN", { cause: result });
15
+ }
16
+ return result;
17
+ }
@@ -145,7 +145,7 @@ function localDiagnostic(error) {
145
145
  };
146
146
  if (reason === 'invalid_state' || reason === 'invalid_questions')
147
147
  return { code: `jev_${reason}`, stage,
148
- message: reason === 'invalid_state' ? 'Jev state must be JSON-serializable text, an object, an array or null. No request was sent.'
148
+ message: reason === 'invalid_state' ? 'Jev state must be JSON-serializable text, an object or an array. No request was sent.'
149
149
  : 'Jev needs a nonempty JSON question map. No request was sent.' };
150
150
  }
151
151
  // Trusted validation can invoke authored factories; its free-form problems may contain thrown text.
@@ -257,7 +257,8 @@ export class WorkflowExtensionService {
257
257
  this.rubricSections = freeze(snapshot(options.rubricSections ?? {}));
258
258
  if (Object.entries(this.rubricSections).some(([name, text]) => !name.trim() || typeof text !== 'string' || !text.trim()))
259
259
  throw new ExtensionServiceError('configuration', 'Rubric sections require nonempty names and text.');
260
- this.limits = { ...defaults, ...options.limits };
260
+ // A field given as undefined is an omitted one: it keeps its default.
261
+ this.limits = { ...defaults, ...Object.fromEntries(Object.entries(options.limits ?? {}).filter(([, value]) => value !== undefined)) };
261
262
  for (const key of Object.keys(ceilings)) {
262
263
  const value = this.limits[key];
263
264
  if (value === null && ['deadlineMs', 'maxAgentCalls', 'maxJudgeCalls', 'maxToolCalls'].includes(key))
package/dist/extension.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { Compile } from 'typebox/compile';
4
+ import { assertHostTools, SUBMIT_TOOL } from './host-tools.js';
4
5
  import { Text } from '@earendil-works/pi-tui';
5
6
  import { createReadToolDefinition, createBashToolDefinition, createEditToolDefinition, createWriteToolDefinition, createGrepToolDefinition, createFindToolDefinition, createLsToolDefinition, VERSION as PI_VERSION, } from '@earendil-works/pi-coding-agent';
6
7
  import { formatWorkflowTree, inspectWorkflow, AUTHOR_SKILL_NAME } from '@parcha/agentrun-dsl';
@@ -8,6 +9,7 @@ import { createJevRunner } from '@parcha/agentrun-jev';
8
9
  import { createPiHostRunner, PI_MODEL_SETUP_MESSAGE } from './host-session.js';
9
10
  import { piHostLine } from './pi-host.js';
10
11
  import { workflowToolContext } from './tool-context.js';
12
+ import { effectResultOf } from './effect-result.js';
11
13
  import { WorkflowExtensionService, ExtensionServiceError, extensionStructuralLimits } from './extension-service.js';
12
14
  import { demoInput, demoSearchTool, demoWorkflow, scriptedDemoDeps } from './demo.js';
13
15
  import { cleanText as safe, formatRunReport, modelJson } from './presentation.js';
@@ -27,6 +29,8 @@ const constructors = {
27
29
  write: createWriteToolDefinition, grep: createGrepToolDefinition, find: createFindToolDefinition, ls: createLsToolDefinition,
28
30
  };
29
31
  const mutatingTools = new Set(['bash', 'edit', 'write']);
32
+ /** The extension's own tool's name, which a host tool may not take. */
33
+ const toolName = 'agentrun';
30
34
  const help = 'Describe a task: /agentrun <what you want done>\nTry support triage: /agentrun triage (fictional, no model calls)\nInspect: /agentrun · Change input: /agentrun input\nSave: /agentrun save <name> · Load: /agentrun load <name>\nLibrary: /agentrun list · History: /agentrun history\nRun: /agentrun run · Dry run: /agentrun dry-run · SOP text: /agentrun sop <file> · Setup: /agentrun status\nStop: /agentrun stop (closing the inspector does not stop a run)\nOther examples: /agentrun demo | demo empty | demo live';
31
35
  const textResult = (text, details = {}) => ({ content: [{ type: 'text', text: safe(text) }], details });
32
36
  function singleTextInput(workflow) {
@@ -54,7 +58,8 @@ export function createAgentRunExtension(options = {}) {
54
58
  throw new Error('onWorkflowEvent must be a host callback');
55
59
  if (options.sop !== undefined && (typeof options.sop !== 'string' || !options.sop.trim()))
56
60
  throw new Error('sop must be nonempty text');
57
- const limits = { ...nativeLimits, ...options.runtimeLimits };
61
+ // A field given as undefined is an omitted one: it keeps its default.
62
+ const limits = { ...nativeLimits, ...Object.fromEntries(Object.entries(options.runtimeLimits ?? {}).filter(([, value]) => value !== undefined)) };
58
63
  for (const [key, value] of Object.entries(limits)) {
59
64
  if (!(key in nativeLimits) || value !== null && (!Number.isSafeInteger(value) || value < 1 || value > nativeLimits[key])) {
60
65
  throw new Error('runtimeLimits must use known fields with positive bounded integers or explicit null');
@@ -76,21 +81,8 @@ function registerExtension(pi, configuration) {
76
81
  const toolSet = (ctx, trusted = false) => {
77
82
  if (configuration.hostTools) {
78
83
  const supplied = configuration.hostTools(ctx);
79
- if (!Array.isArray(supplied) || supplied.length > 100)
80
- throw new Error('Host must supply at most 100 explicit tool definitions');
81
- const names = new Set();
82
- return supplied.map(tool => {
83
- if (!tool || typeof tool.name !== 'string' || !tool.name.trim() || tool.name.length > 200
84
- || ['agentrun', 'submit'].includes(tool.name) || names.has(tool.name))
85
- throw new Error('Host tools contain an invalid, reserved or duplicate name');
86
- if (typeof tool.execute !== 'function' || !tool.parameters || typeof tool.parameters !== 'object')
87
- throw new Error('Host tools require an executable definition and parameter schema');
88
- Compile(tool.parameters);
89
- if (tool.resultSchema)
90
- Compile(tool.resultSchema);
91
- names.add(tool.name);
92
- return { ...tool, parameters: structuredClone(tool.parameters), ...(tool.resultSchema ? { resultSchema: structuredClone(tool.resultSchema) } : {}) };
93
- });
84
+ assertHostTools(supplied, { reserved: [toolName, SUBMIT_TOOL], executable: true });
85
+ return supplied.map(tool => ({ ...tool, parameters: structuredClone(tool.parameters), ...(tool.resultSchema ? { resultSchema: structuredClone(tool.resultSchema) } : {}) }));
94
86
  }
95
87
  const active = new Set(pi.getActiveTools());
96
88
  const builtins = new Set(pi.getAllTools().filter(t => t.sourceInfo.source === 'builtin').map(t => t.name));
@@ -286,6 +278,8 @@ function registerExtension(pi, configuration) {
286
278
  throw new ToolInputValidationError(node.label, tool.name, error.problems);
287
279
  throw error;
288
280
  }
281
+ // A call the receipts refused is an effect whose outcome is unknown: the DSL's signal, thrown, never a returned value.
282
+ result = effectResultOf(result, node.label);
289
283
  return !configuration.hostTools && ['search', 'support_demo_lookup', 'support_demo_handoff'].includes(node.tool ?? '') ? result.details : { content: result.content, ...(result.details === undefined ? {} : { details: result.details }) };
290
284
  },
291
285
  };
@@ -475,10 +469,10 @@ function registerExtension(pi, configuration) {
475
469
  await pending;
476
470
  };
477
471
  pi.registerTool({
478
- name: 'agentrun', label: 'AgentRun workflow',
472
+ name: toolName, label: 'AgentRun workflow',
479
473
  description: `Use the ${AUTHOR_SKILL_NAME} skill to compose workflows.` + ' Describe lists available tools and their schemas. Inspect or run an AgentRun DSL workflow in this Pi session. First inspect to show its graph. Dry-run checks the inspected workflow on synthetic values (or the given input) with no model, Jev or tool call; code runs only with the user command /agentrun dry-run --trusted. Agent nodes capture the active Pi model when execution starts. Code needs the user command /agentrun run --trusted for each run. ' + (configuration.hostTools
480
474
  ? 'Only the explicit host-configured tools are available, including during trusted runs. Outer permission hooks are not inherited. '
481
- : 'Enabled read-only Pi built-ins are available to declared steps. Shell/write/edit need the trusted command for each run; custom extension tools and permission hooks are not inherited. The search and support_demo tools use fictional supplied sources only. ') + 'Save stores a named procedure revision, not its input or permission. Load preflights current host capabilities. Run always restarts; there is no checkpoint resume. Scripted demos stay scripted on rerun.',
475
+ : 'Enabled read-only Pi built-ins are available to declared steps. Shell/write/edit need the trusted command for each run; custom extension tools and permission hooks are not inherited. The search and support_demo tools use fictional supplied sources only. ') + 'Save stores a named procedure revision, not its input or permission. Load preflights current host capabilities. A run this session left interrupted or failed resumes from its journal when its draft, input, SOP and tools are unchanged; any other run starts new. Scripted demos stay scripted on rerun.',
482
476
  parameters: workflowToolParameters(),
483
477
  executionMode: 'sequential',
484
478
  renderResult(result, options) {
@@ -1,4 +1,5 @@
1
1
  import { Agent } from '@earendil-works/pi-agent-core';
2
+ import { SUBMIT_TOOL } from './host-tools.js';
2
3
  import { createPiRunner } from './runner.js';
3
4
  import { workflowToolContext } from './tool-context.js';
4
5
  export const PI_MODEL_SETUP_MESSAGE = 'AgentRun requires an active Pi model. Use /login to connect a provider, then /model to select it. Try /agentrun demo without model access.';
@@ -28,7 +29,7 @@ export function createPiHostSessionFactory(context) {
28
29
  parameters: tool.parameters,
29
30
  async execute(id, args, signal, onUpdate) {
30
31
  const result = await tool.execute(id, args, signal, onUpdate, toolContext);
31
- if (tool.name === 'submit' && result.details?.accepted === true)
32
+ if (tool.name === SUBMIT_TOOL && result.details?.accepted === true)
32
33
  delivered = true;
33
34
  return result;
34
35
  },
@@ -0,0 +1,12 @@
1
+ /** The tool a workflow node delivers its result with (the runner's and the durable record tool's), so no host tool may
2
+ * take its name. */
3
+ export declare const SUBMIT_TOOL = "submit";
4
+ /**
5
+ * Refuses a host's tool list unless it is an array of at most 100 tools, each with a name of 1 to 200 characters that
6
+ * is not blank, not in `reserved` and not repeated, and an object parameter schema (and result schema, when given) the
7
+ * compiler takes. With `executable`, each tool also needs an `execute` function. Throws on the first problem.
8
+ */
9
+ export declare function assertHostTools(tools: unknown, options: {
10
+ reserved: readonly string[];
11
+ executable: boolean;
12
+ }): void;
@@ -0,0 +1,30 @@
1
+ // The one check of a host's tool list, wherever a host registers tools: the coding-agent extension (`hostTools`) and
2
+ // the pi-durable workflow tool (`tools`). It loads without pi's coding agent, agent core or TUI.
3
+ import { Compile } from 'typebox/compile';
4
+ /** The most tools a host may register. */
5
+ const MAX_HOST_TOOLS = 100;
6
+ /** The tool a workflow node delivers its result with (the runner's and the durable record tool's), so no host tool may
7
+ * take its name. */
8
+ export const SUBMIT_TOOL = 'submit';
9
+ /**
10
+ * Refuses a host's tool list unless it is an array of at most 100 tools, each with a name of 1 to 200 characters that
11
+ * is not blank, not in `reserved` and not repeated, and an object parameter schema (and result schema, when given) the
12
+ * compiler takes. With `executable`, each tool also needs an `execute` function. Throws on the first problem.
13
+ */
14
+ export function assertHostTools(tools, options) {
15
+ if (!Array.isArray(tools) || tools.length > MAX_HOST_TOOLS)
16
+ throw new Error(`Host must supply at most ${MAX_HOST_TOOLS} explicit tool definitions`);
17
+ const names = new Set();
18
+ for (const tool of tools) {
19
+ if (!tool || typeof tool.name !== 'string' || !tool.name.trim() || tool.name.length > 200
20
+ || options.reserved.includes(tool.name) || names.has(tool.name))
21
+ throw new Error('Host tools contain an invalid, reserved or duplicate name');
22
+ if ((options.executable && typeof tool.execute !== 'function') || !tool.parameters || typeof tool.parameters !== 'object') {
23
+ throw new Error(options.executable ? 'Host tools require an executable definition and parameter schema' : 'Host tools require a parameter schema');
24
+ }
25
+ Compile(tool.parameters);
26
+ if (tool.resultSchema)
27
+ Compile(tool.resultSchema);
28
+ names.add(tool.name);
29
+ }
30
+ }
package/dist/runner.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Compile } from "typebox/compile";
2
2
  import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, ModelRuntime, } from "@earendil-works/pi-coding-agent";
3
+ import { SUBMIT_TOOL } from "./host-tools.js";
3
4
  import { PiRunError } from "./run-error.js";
4
5
  import { nodeThinking } from "./thinking.js";
5
6
  export { PiRunError } from "./run-error.js";
@@ -49,7 +50,7 @@ export function createPiRunner(options) {
49
50
  const timeoutMs = options.timeoutMs === null ? null : positive(options.timeoutMs ?? 120_000, "timeoutMs");
50
51
  const names = new Set();
51
52
  for (const tool of options.tools ?? []) {
52
- if (tool.name === "submit" || names.has(tool.name))
53
+ if (tool.name === SUBMIT_TOOL || names.has(tool.name))
53
54
  throw new Error(`Reserved or duplicate Pi tool: ${tool.name}`);
54
55
  names.add(tool.name);
55
56
  }
@@ -96,7 +97,7 @@ export function createPiRunner(options) {
96
97
  let unsubscribe;
97
98
  const result = (text, ok) => ({ content: [{ type: "text", text }], details: { accepted: ok } });
98
99
  const submit = {
99
- name: "submit", label: "Submit result", description: "Deliver a result matching the required JSON Schema. Rejection feedback must be corrected in this session.",
100
+ name: SUBMIT_TOOL, label: "Submit result", description: "Deliver a result matching the required JSON Schema. Rejection feedback must be corrected in this session.",
100
101
  parameters,
101
102
  executionMode: "sequential",
102
103
  async execute(id, args) {
@@ -162,7 +163,7 @@ export function createPiRunner(options) {
162
163
  cwd, model: options.model, modelRuntime: options.modelRuntime,
163
164
  thinkingLevel: nodeThinking(request.thinking, options.thinkingLevel), maxTurns: maxTurns ?? undefined, resourceLoader, settingsManager,
164
165
  sessionManager,
165
- tools: [...requested, "submit"],
166
+ tools: [...requested, SUBMIT_TOOL],
166
167
  customTools: [...(options.tools ?? []).filter(tool => requested.includes(tool.name)).map(tool => ({
167
168
  ...tool,
168
169
  executionMode: "sequential",
@@ -192,7 +193,7 @@ export function createPiRunner(options) {
192
193
  unsubscribe = session.subscribe(event => {
193
194
  // Native events carry correlation IDs, including SDK-rejected calls. Custom
194
195
  // sessions without IDs use execute's counter so one attempt is not counted twice.
195
- if (event.type === "tool_execution_start" && event.toolName === "submit" && event.toolCallId !== undefined && !accepted && !stopped && !fatal) {
196
+ if (event.type === "tool_execution_start" && event.toolName === SUBMIT_TOOL && event.toolCallId !== undefined && !accepted && !stopped && !fatal) {
196
197
  submissions++;
197
198
  pendingSubmissions.set(event.toolCallId, {
198
199
  ...(Object.hasOwn(event, "args") ? { args: structuredClone(event.args) } : {}), executed: false,
@@ -200,7 +201,7 @@ export function createPiRunner(options) {
200
201
  if (maxSubmissions !== null && submissions > maxSubmissions)
201
202
  stop("submission_limit");
202
203
  }
203
- if (event.type === "tool_execution_end" && event.toolName === "submit") {
204
+ if (event.type === "tool_execution_end" && event.toolName === SUBMIT_TOOL) {
204
205
  if (event.toolCallId !== undefined)
205
206
  pendingSubmissions.delete(event.toolCallId);
206
207
  if (!accepted && !stopped && !fatal && maxSubmissions !== null && submissions >= maxSubmissions)
@@ -228,7 +229,8 @@ export function createPiRunner(options) {
228
229
  throw new PiRunError(stopped, turns, submissions);
229
230
  if (accepted)
230
231
  return submission;
231
- throw new PiRunError(maxTurns !== null && turns >= maxTurns ? "turn_limit" : "no_submission", turns, submissions);
232
+ // The loop ends only on one of the three above or at the turn cap, so what is left is the cap.
233
+ throw new PiRunError("turn_limit", turns, submissions);
232
234
  }
233
235
  catch (error) {
234
236
  if (fatal)
@@ -12,7 +12,7 @@ A workflow is {"v":2,"name","schemas","input"?,"output","root"}.
12
12
  - The input contract: the run starts from the input object and nothing else. `input.schemaId`, when present, is enforced before the first node. A node may read only input keys and keys an earlier node wrote.
13
13
  - `output.schemaId` validates the final value; `output.path` selects it from state and must name an input key or a key an earlier node wrote.
14
14
  - State is one object. Each step writes its result under one key: its `as`, or, for an agent, decide, extract or code node without `as`, its label. A report writes `report_markdown`. A code node without `as` that returns an object merges that object's top-level fields instead.
15
- - Keys that begin with `$` are engine-owned: `$host` (host state), `<as>$answers` (raw judge answers and confidences) and `<as>$verify` (verifier results). Read them; never write one. No `as` may begin with `$`, and neither may the label of an agent, decide, extract or code node without `as`.
15
+ - Keys that hold a `$` are engine-owned: `$host` (host state), `<as>$answers` (raw judge answers and confidences) and `<as>$verify` (verifier results). Read them; never write one. No `as` may hold a `$`, and neither may the label of an agent, decide, extract or code node without `as`.
16
16
  - `{dot.path}` placeholders are replaced from state in `state` maps (including `verify.state` and `ask.state`), call `args`, `input`, `env` and `produces`, a child workflow's `input`, and `escalate.summary`; `pick` and `sift` `describe` templates also see `{item...}`. A placeholder that is the whole string keeps its value's type. Every key a state map, call or child input interpolates must be produced upstream. `instructions`, `code` and `command` are literal and never interpolated.
17
17
 
18
18
  ## Node kinds
@@ -1,6 +1,6 @@
1
1
  import { Type } from 'typebox';
2
2
  import { type AuthorHostAddendum, type DryRunResult, type WorkflowDeps } from '@parcha/agentrun-dsl';
3
- import type { WorkflowExtensionService } from './extension-service.js';
3
+ import { type WorkflowExtensionService } from './extension-service.js';
4
4
  export declare const WORKFLOW_TOOL_ACTIONS: readonly ["describe", "inspect", "dry-run", "run", "save", "load", "list"];
5
5
  /** The tool's fields: one action, and the fields an action reads. A host's tool may add its own. */
6
6
  export declare const workflowToolProperties: () => {
@@ -2,6 +2,7 @@
2
2
  // and the dry run. Nothing here imports pi's coding agent, its agent core or its TUI, so a pi-durable host can use it.
3
3
  import { Type } from 'typebox';
4
4
  import { compileTransform, dryRunWorkflow, formatWorkflowTree, inspectWorkflow, loadAuthorReference, renderAuthorHostAddendum, } from '@parcha/agentrun-dsl';
5
+ import { ExtensionServiceError } from './extension-service.js';
5
6
  import { PI_HOST_ADDENDUM } from './host-addendum.js';
6
7
  export const WORKFLOW_TOOL_ACTIONS = ['describe', 'inspect', 'dry-run', 'run', 'save', 'load', 'list'];
7
8
  /** The tool's fields: one action, and the fields an action reads. A host's tool may add its own. */
@@ -35,7 +36,9 @@ export function inspectCandidate(checker, candidate, input) {
35
36
  inspection = checker.preflight(candidate, input);
36
37
  }
37
38
  catch (error) {
38
- throw new Error(`Cannot inspect workflow: ${error instanceof Error ? error.message : 'Invalid workflow'}`);
39
+ const message = `Cannot inspect workflow: ${error instanceof Error ? error.message : 'Invalid workflow'}`;
40
+ // The service's refusal keeps its type and code, so a host branches on it as it does for a run; either keeps its cause.
41
+ throw error instanceof ExtensionServiceError ? Object.assign(new ExtensionServiceError(error.code, message), { cause: error }) : new Error(message, { cause: error });
39
42
  }
40
43
  return { inspection, tree: formatWorkflowTree(inspection) };
41
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parcha/agentrun-pi",
3
- "version": "0.1.0-beta.21",
3
+ "version": "0.1.0-beta.26",
4
4
  "description": "Pi extension for building, running and evaluating AgentRun workflows",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -50,12 +50,12 @@
50
50
  "@earendil-works/pi-coding-agent": "1.1.0",
51
51
  "@earendil-works/pi-durable": "1.1.0",
52
52
  "@earendil-works/pi-tui": "1.1.0",
53
- "@parcha/pi-durable-disk": "0.1.0-beta.21",
53
+ "@parcha/pi-durable-disk": "0.1.0-beta.26",
54
54
  "typebox": "1.3.7"
55
55
  },
56
56
  "dependencies": {
57
- "@parcha/agentrun-dsl": "0.1.0-beta.21",
58
- "@parcha/agentrun-jev": "0.1.0-beta.21"
57
+ "@parcha/agentrun-dsl": "0.1.0-beta.26",
58
+ "@parcha/agentrun-jev": "0.1.0-beta.26"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@earendil-works/pi-agent-core": "*",