@parcha/agentrun-pi 0.1.0-beta.20 → 0.1.0-beta.23
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 +32 -12
- package/dist/durable/decision-recovery.js +10 -2
- package/dist/durable/decisions.d.ts +20 -2
- package/dist/durable/decisions.js +58 -6
- package/dist/durable/index.d.ts +3 -2
- package/dist/durable/index.js +3 -2
- package/dist/durable/node-runner.d.ts +7 -1
- package/dist/durable/node-runner.js +19 -4
- package/dist/durable/readers.d.ts +4 -0
- package/dist/durable/readers.js +20 -0
- package/dist/durable/receipts.d.ts +4 -1
- package/dist/durable/recording.js +29 -11
- package/dist/durable/store.js +5 -3
- package/dist/durable/workflow-tool.js +3 -0
- package/dist/effect-result.d.ts +7 -0
- package/dist/effect-result.js +17 -0
- package/dist/extension-service.js +3 -2
- package/dist/extension.js +17 -31
- package/dist/host-tools.d.ts +9 -0
- package/dist/host-tools.js +27 -0
- package/dist/skills/author/references/language.md +1 -1
- package/dist/workflow-session.js +3 -3
- package/dist/workflow-store.js +8 -8
- package/package.json +4 -4
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
|
|
|
@@ -17,9 +17,11 @@ pi install npm:@parcha/agentrun-pi@beta -l
|
|
|
17
17
|
pi --offline
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
`-l` writes project settings in `.pi/settings.json`. Omit it to install for all your Pi sessions. `--offline` skips startup downloads; it does not disable model calls. No AgentRun checkout or build is needed.
|
|
20
|
+
`pi --offline` opens Pi's terminal interface, so run it in an interactive terminal; with no terminal attached it prints nothing and exits. `-l` writes project settings in `.pi/settings.json`. Omit it to install for all your Pi sessions. `--offline` skips startup downloads; it does not disable model calls. No AgentRun checkout or build is needed.
|
|
21
21
|
|
|
22
|
-
On first launch, Pi asks
|
|
22
|
+
On first launch, Pi asks "Trust project folder?" before loading the extension, with five choices: Trust, Trust parent folder, Trust (this session only), Do not trust, and Do not trust (this session only). Choose Trust, or Trust (this session only), to load it. If Pi is already open, use `/reload`. The current draft is restored from a persisted Pi session branch when available; named definition saves are stored separately.
|
|
23
|
+
|
|
24
|
+
With no model configured, `pi --offline` prints two warnings at startup: `Warning: fd not found. Offline mode enabled, skipping download.` (the optional `fd` tool is not installed and `--offline` skips fetching it) and `Warning: No models available. Use /login to log into a provider via OAuth or API key.` Both are expected here and the scripted demo needs neither. The `[Skills]` list shows `agentrun-author` and the `[Extensions]` list shows `@parcha/agentrun-pi` when the extension loaded.
|
|
23
25
|
|
|
24
26
|
For live tasks, configure model access in Pi first. AgentRun uses Pi's current model and authentication; it needs no separate model configuration. The scripted demo below works without provider access.
|
|
25
27
|
|
|
@@ -43,6 +45,8 @@ For an offline Pi startup, use `pi --offline`. Without that flag, Pi may downloa
|
|
|
43
45
|
|
|
44
46
|
## Preview, run, save, reuse
|
|
45
47
|
|
|
48
|
+
**This section needs a configured model.** Authoring a workflow from a task is a model call, so the preview below, and the `input`, `run`, `save` and `load` steps after it, are for a Pi with a model selected. With no model, `/agentrun <task>` answers `AgentRun requires an active Pi model. Use /login to connect a provider, then /model to select it. Try /agentrun demo without model access.` and `/agentrun demo` (above) is the one workflow you have: `/agentrun input`, `run` and `save` act on that scripted demo, not on a release review.
|
|
49
|
+
|
|
46
50
|
```text
|
|
47
51
|
/agentrun Preview a reusable workflow that identifies breaking changes and migration actions in supplied release notes. Do not run it yet.
|
|
48
52
|
```
|
|
@@ -65,7 +69,7 @@ After inspecting a definition, save it and reuse it in a new Pi session in the s
|
|
|
65
69
|
/agentrun run
|
|
66
70
|
```
|
|
67
71
|
|
|
68
|
-
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.
|
|
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).
|
|
69
73
|
|
|
70
74
|
| Command | What happens |
|
|
71
75
|
| --- | --- |
|
|
@@ -84,7 +88,7 @@ Loading starts with empty input and does not execute. A save retains the definit
|
|
|
84
88
|
|
|
85
89
|
A progress widget shows active steps and the number finished. Results distinguish completion, escalation, failure, and interruption. Decision records show supplied evidence, criteria, answer, and route, not hidden reasoning or proof of correctness.
|
|
86
90
|
|
|
87
|
-
Pi 1.1.0 cannot export a fresh session containing only slash-command results. To save an offline research receipt from the
|
|
91
|
+
Pi 1.1.0 cannot export a fresh session containing only slash-command results. To save an offline research receipt, run [`examples/research-live.mjs`](https://github.com/Parcha-ai/agentrun/blob/main/examples/research-live.mjs) from a checkout of the repository (the `examples/` directory is in the repository, not in the npm package): `node examples/research-live.mjs --out research-result.json`. This runs the scripted example again and writes its result; it does not export the Pi session.
|
|
88
92
|
|
|
89
93
|
A live run is durable. Its journal is kept in `.pi/agentrun/runs/<run id>/journal.json`, and `/agentrun run` after an interruption (`/agentrun stop`, a closed session, a killed process) or a failure continues the same run: committed steps are answered from the journal, a tool call that completed is answered from its receipt and is not made again, and the next step runs. The run is bound to its workflow, input, SOP text and tool names; a change to any of them starts a new run. A tool call that was in flight when the run stopped has an unknown outcome and is never made again: the resumed run fails naming it, and the person who knows whether it happened reconciles it before a new run. An agent step that was interrupted starts over from its first turn. A run that completed or escalated is not resumed; `/agentrun run` starts a new one. Scripted demos keep no journal.
|
|
90
94
|
|
|
@@ -103,13 +107,14 @@ const revisions = await store.list(); // every revision,
|
|
|
103
107
|
|
|
104
108
|
The argument is a project directory, not the workflows directory: the extension keeps its records for `ctx.cwd` in the same place, so a host that passes that directory and the extension read each other's records. Storage needs real directories and regular files (a symbolic link in the path or among the records fails with `WorkflowStoreError` code `unsafe_path`), and every load checks the record's metadata and the SHA-256 of its workflow. A `<name>/` directory copied into the workflows directory loads. A saved workflow is a procedure, not authority: the host still checks it against its current capabilities before it runs.
|
|
105
109
|
|
|
106
|
-
###
|
|
110
|
+
### Demo modes
|
|
107
111
|
|
|
108
112
|
| Command | What it demonstrates |
|
|
109
113
|
| --- | --- |
|
|
110
114
|
| `/agentrun demo` | Scripted successful execution over the bundled fictional sources, without model calls. |
|
|
111
115
|
| `/agentrun demo empty` | Scripted insufficient-evidence path, without model calls. |
|
|
112
116
|
| `/agentrun demo live` | Real Pi agent steps and Jev system one decisions over the same fictional sources. |
|
|
117
|
+
| `/agentrun triage` | A second scripted workflow, support-ticket triage over a fictional ticket, without model calls. |
|
|
113
118
|
|
|
114
119
|
Live mode uses your active Pi model and requires configured TypeSafe access for Jev. It still does not search the web or prove accuracy on real research. See the [Jev adapter configuration](https://github.com/Parcha-ai/agentrun/tree/main/packages/jev#readme). Live calls can incur usage charges; scripted mode needs no provider access.
|
|
115
120
|
|
|
@@ -160,7 +165,7 @@ The native model-facing interface starts with `agentrun` and `action: "describe"
|
|
|
160
165
|
|
|
161
166
|
### Configure the native extension from a host
|
|
162
167
|
|
|
163
|
-
Hosts that own a restricted corpus, an approved Jev transport, or shared accounting can configure the same native extension:
|
|
168
|
+
Hosts that own a restricted corpus, an approved Jev transport, or shared accounting can configure the same native extension. This is a sketch, not a runnable sample: `approvedToolsFor`, `makeHostJudge` and `sharedBudget` stand for your own host's code. See [Integrate with your application](../../docs/host-integration.md) for what a host supplies.
|
|
164
169
|
|
|
165
170
|
```js
|
|
166
171
|
import { createAgentRunExtension } from '@parcha/agentrun-pi/extension';
|
|
@@ -201,7 +206,7 @@ An operator may select a different active Pi model between inspection and execut
|
|
|
201
206
|
|
|
202
207
|
## SDK: embed the runner
|
|
203
208
|
|
|
204
|
-
The native extension handles configuration above. For another Pi extension that already has a context:
|
|
209
|
+
The native extension handles configuration above. For another Pi extension that already has a context, this sketch shows the call (not runnable as it stands: `ctx` is the context Pi hands your extension, and `workflow`, `input` and `allowedTools` are yours; see [Integrate with your application](../../docs/host-integration.md)):
|
|
205
210
|
|
|
206
211
|
```js
|
|
207
212
|
import { runWorkflow } from '@parcha/agentrun-dsl';
|
|
@@ -223,7 +228,7 @@ The child model delivers `{value: result}` through its `submit` tool. Schema and
|
|
|
223
228
|
|
|
224
229
|
## SDK: retain candidates and check fixed fixtures
|
|
225
230
|
|
|
226
|
-
`authorWorkflow` lives in `@parcha/agentrun-dsl` beside the language it teaches. It saves candidate files separately from the native extension's current draft and named procedure revisions. Give it a Pi runner with no host tools, and bound its submissions by the candidate limit:
|
|
231
|
+
`authorWorkflow` lives in `@parcha/agentrun-dsl` beside the language it teaches. It saves candidate files separately from the native extension's current draft and named procedure revisions. Give it a Pi runner with no host tools, and bound its submissions by the candidate limit. This is a sketch, not runnable as it stands: `options` is your configured `PiRunnerOptions` and `checkAgainstYourFixtures` is your own function (see [Integrate with your application](../../docs/host-integration.md)):
|
|
227
232
|
|
|
228
233
|
```js
|
|
229
234
|
import { authorWorkflow } from '@parcha/agentrun-dsl';
|
|
@@ -244,11 +249,26 @@ The host owns `checkAgainstYourFixtures`: return diagnostics, or `[]` to accept.
|
|
|
244
249
|
|
|
245
250
|
With `rubricSections`, every supplied section must appear on every generated LLM node, including child workflows. The author conservatively rejects Jev nodes, semantic `ask` predicates, and `verify` clauses under that policy; those need a separately reviewed question contract. Schema properties named `node`, `verify`, or `predicate` remain ordinary data. Supply the authoritative rubric as `deps.sop` when executing the candidate.
|
|
246
251
|
|
|
247
|
-
The
|
|
252
|
+
The package also installs a command line, `agentrun-pi`. Try it from an empty directory after `npm install @parcha/agentrun-pi@beta`:
|
|
253
|
+
|
|
254
|
+
```sh
|
|
255
|
+
npx agentrun-pi --help
|
|
256
|
+
npx agentrun-pi demo
|
|
257
|
+
cp node_modules/@parcha/agentrun-dsl/skills/author/examples/review-release-notes.json workflow.json
|
|
258
|
+
npx agentrun-pi validate workflow.json --inputs notes
|
|
259
|
+
```
|
|
260
|
+
|
|
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
|
+
|
|
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.
|
|
248
268
|
|
|
249
269
|
## Sign in, pick a model, check keys
|
|
250
270
|
|
|
251
|
-
`@parcha/agentrun-pi/signin` is what a demo, or your own app, runs before its first model call. 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.
|
|
252
272
|
|
|
253
273
|
```js
|
|
254
274
|
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
|
|
@@ -290,7 +310,7 @@ These commands create a separate `agentrun-pi-project` beside the checkout. You
|
|
|
290
310
|
|
|
291
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.
|
|
292
312
|
|
|
293
|
-
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.
|
|
294
314
|
|
|
295
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.
|
|
296
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,
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
2
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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. */
|
package/dist/durable/index.d.ts
CHANGED
|
@@ -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 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";
|
package/dist/durable/index.js
CHANGED
|
@@ -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). */
|
|
@@ -17,10 +17,24 @@ import { closeNodeAttempt, runNodeAttempt } from "./node.js";
|
|
|
17
17
|
import { DELIVERY_ATTEMPTS, lintRecordRoot, submitFooter } from "./record.js";
|
|
18
18
|
import { workspaceRecordFile } from "./record-file.js";
|
|
19
19
|
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.
|
|
20
|
+
/** A node's configuration: what its conversation's prompt and `submit` tool are drawn from. `input` is the node's
|
|
21
|
+
* prompt state, the JSON object its user text was stringified from, for a reader that shows what the node was given;
|
|
22
|
+
* it is absent when that text is not a JSON object, and in a document an older build wrote. A fork of the conversation
|
|
23
|
+
* starts without it, as with the record: the runner never attaches a fork, so a fork is no node and waits for nothing. */
|
|
21
24
|
export const NodeDoc = defineDoc({
|
|
22
|
-
kind: "agentrun.node", version: 1, scope: "conversation", history: "latest", fork: "
|
|
25
|
+
kind: "agentrun.node", version: 1, scope: "conversation", history: "latest", fork: "initial", initial: () => ({ system: "", label: "", schema: null, fileKey: null }),
|
|
23
26
|
});
|
|
27
|
+
/** The JSON object a node's user text holds, or nothing: the interpreter sends a node its prompt state stringified. */
|
|
28
|
+
function inputOf(user) {
|
|
29
|
+
let value;
|
|
30
|
+
try {
|
|
31
|
+
value = JSON.parse(user);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? { input: value } : {};
|
|
37
|
+
}
|
|
24
38
|
/** A node that delivered no record. `final` says a second attempt would spend the same again: the model did not
|
|
25
39
|
* answer, or the delivery attempts are spent. `unanswered` is pi's own account of a submission the model did not
|
|
26
40
|
* answer: its reason (`failed`, `aborted`, ...) and detail. */
|
|
@@ -165,6 +179,7 @@ export function nodeRunner(host, options = {}) {
|
|
|
165
179
|
const offered = (listed(agent.tools) ? agent.tools : listed(agent.extensions) ? agent.extensions.flatMap((selected) => selected.tools ?? []) : [])
|
|
166
180
|
.map((offer) => offer.name).filter((name) => name !== "submit");
|
|
167
181
|
const system = [...node.system, host.stance?.(node, { tools: offered })].filter(Boolean).join("\n\n");
|
|
182
|
+
const input = inputOf(node.user);
|
|
168
183
|
const reviewers = [
|
|
169
184
|
...(host.verify ? [(candidate, at) => host.verify(candidate, node, at)] : []),
|
|
170
185
|
...(review ? [async (candidate) => {
|
|
@@ -183,14 +198,14 @@ export function nodeRunner(host, options = {}) {
|
|
|
183
198
|
agent: withNodes(agent),
|
|
184
199
|
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
200
|
init: async (tx, id) => {
|
|
186
|
-
Object.assign(await tx.doc(NodeDoc, id), { system, label, schema: node.schema, fileKey: record.file?.key ?? null });
|
|
201
|
+
Object.assign(await tx.doc(NodeDoc, id), { system, label, schema: node.schema, fileKey: record.file?.key ?? null, ...input });
|
|
187
202
|
await host.init?.(node, tx, id);
|
|
188
203
|
},
|
|
189
204
|
adopt: async (tx, id, stored) => {
|
|
190
205
|
const held = await tx.doc(NodeDoc, id);
|
|
191
206
|
if (held.schema !== null)
|
|
192
207
|
return;
|
|
193
|
-
Object.assign(held, { system: typeof stored.system === "string" ? stored.system : system, label, schema: node.schema, fileKey: record.file?.key ?? null });
|
|
208
|
+
Object.assign(held, { system: typeof stored.system === "string" ? stored.system : system, label, schema: node.schema, fileKey: record.file?.key ?? null, ...input });
|
|
194
209
|
// The older build selected an extension of its own for this conversation, which is not installed here.
|
|
195
210
|
const own = typeof stored.extension === "string" ? [{ name: stored.extension }] : [];
|
|
196
211
|
const given = withNodes(agent);
|
|
@@ -16,6 +16,10 @@ export type JournalView = {
|
|
|
16
16
|
export declare function withDurableFile<T>(file: string, read: (db: Database) => T): T | null;
|
|
17
17
|
/** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
|
|
18
18
|
export declare function durableDoc<T = Record<string, unknown>>(file: string, kind: string, key?: string): T | undefined;
|
|
19
|
+
/** The decision rows (`agentrun.decisions`) of the run's store `file`, in commit order, in whichever form the store holds: the single
|
|
20
|
+
* document's `receipts` list, or the keyed documents (one row each, in the order of their keys). A reader that folds decisions from the
|
|
21
|
+
* store uses this and not `durableDoc(file, "agentrun.decisions").receipts`, which is nothing in the keyed form. */
|
|
22
|
+
export declare function durableDecisions(file: string): Array<Record<string, unknown>>;
|
|
19
23
|
/** The journal `key` holds in the run's store `file`, as the last commit left it; undefined when the file holds no
|
|
20
24
|
* such journal or none has bound it. `effects` are every effect the journal admitted, once each, in the order they were
|
|
21
25
|
* admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
|
package/dist/durable/readers.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { apply } from "@earendil-works/chord/delta";
|
|
8
|
+
import { decisionKey } from "./decisions.js";
|
|
8
9
|
import { effectKey } from "./store.js";
|
|
9
10
|
/** The run's store `file`, opened read-only for `read`; null when there is no such file. Every query of one `read` runs
|
|
10
11
|
* in one read transaction, so what it returns is one commit's view, whatever a writer commits meanwhile. */
|
|
@@ -69,6 +70,25 @@ function itemEffects(db, key, table, into) {
|
|
|
69
70
|
export function durableDoc(file, kind, key) {
|
|
70
71
|
return (withDurableFile(file, (db) => sessionDoc(db, kind, key)) ?? undefined);
|
|
71
72
|
}
|
|
73
|
+
/** The decision rows (`agentrun.decisions`) of the run's store `file`, in commit order, in whichever form the store holds: the single
|
|
74
|
+
* document's `receipts` list, or the keyed documents (one row each, in the order of their keys). A reader that folds decisions from the
|
|
75
|
+
* store uses this and not `durableDoc(file, "agentrun.decisions").receipts`, which is nothing in the keyed form. */
|
|
76
|
+
export function durableDecisions(file) {
|
|
77
|
+
return withDurableFile(file, (db) => {
|
|
78
|
+
const single = sessionDoc(db, "agentrun.decisions");
|
|
79
|
+
if (single)
|
|
80
|
+
return Array.isArray(single.receipts) ? single.receipts : [];
|
|
81
|
+
const count = sessionDoc(db, "agentrun.decisions.count")?.count;
|
|
82
|
+
const rows = [];
|
|
83
|
+
if (typeof count === "number")
|
|
84
|
+
for (let place = 0; place < count; place += 1) {
|
|
85
|
+
const row = sessionDoc(db, "agentrun.decisions", decisionKey(place));
|
|
86
|
+
if (row)
|
|
87
|
+
rows.push(row);
|
|
88
|
+
}
|
|
89
|
+
return rows;
|
|
90
|
+
}) ?? [];
|
|
91
|
+
}
|
|
72
92
|
/** The journal `key` holds in the run's store `file`, as the last commit left it; undefined when the file holds no
|
|
73
93
|
* such journal or none has bound it. `effects` are every effect the journal admitted, once each, in the order they were
|
|
74
94
|
* admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
|
|
@@ -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) => {
|
|
@@ -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
|
|
83
|
-
value =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))) {
|
package/dist/durable/store.js
CHANGED
|
@@ -47,6 +47,10 @@ function json(value) {
|
|
|
47
47
|
return item;
|
|
48
48
|
}));
|
|
49
49
|
}
|
|
50
|
+
/** A journal effect from its stored document. */
|
|
51
|
+
function storedEffect(id, e) {
|
|
52
|
+
return { id, name: e.name, argsHash: e.argsHash, status: e.status, session: e.session, result: json(e.result), ...(e.intent ? { intent: json(e.intent) } : {}), ...(e.args !== undefined ? { args: json(e.args) } : {}), ...(Array.isArray(e.calls) ? { calls: json(e.calls) } : {}) };
|
|
53
|
+
}
|
|
50
54
|
/** The journals open in this process. The run's lock keeps every other process off the run, and the journal's generation
|
|
51
55
|
* fences a stale writer; this keeps a second open of one key off it here. Without an owner a host's own keys are
|
|
52
56
|
* tracked; with one, the keys by owner. */
|
|
@@ -100,8 +104,7 @@ async function openDocumentJournal(host, key, bound, options) {
|
|
|
100
104
|
doc.generation += 1;
|
|
101
105
|
const effects = [];
|
|
102
106
|
for (const id of doc.effects) {
|
|
103
|
-
|
|
104
|
-
effects.push({ id, name: e.name, argsHash: e.argsHash, status: e.status, session: e.session, result: json(e.result), ...(e.intent ? { intent: json(e.intent) } : {}), ...(e.args !== undefined ? { args: json(e.args) } : {}), ...(Array.isArray(e.calls) ? { calls: json(e.calls) } : {}) });
|
|
107
|
+
effects.push(storedEffect(id, await tx.doc(EffectDoc, effectKey(key, id), null)));
|
|
105
108
|
}
|
|
106
109
|
return { existing, generation: doc.generation, revision: doc.revision, state: json(doc.state), notes: json(doc.notes), effects };
|
|
107
110
|
}, context).catch((error) => { held.undo(); throw storeFailure(error); });
|
|
@@ -142,7 +145,6 @@ async function openDocumentJournal(host, key, bound, options) {
|
|
|
142
145
|
}, context).then((done) => { revision = done.revision; return done; });
|
|
143
146
|
/** A read in a commit of its own: it writes nothing the caller did not ask for. */
|
|
144
147
|
const read = (body) => inOrder(() => host.commit(body, context));
|
|
145
|
-
const storedEffect = (id, e) => ({ id, name: e.name, argsHash: e.argsHash, status: e.status, session: e.session, result: json(e.result), ...(e.intent ? { intent: json(e.intent) } : {}), ...(e.args !== undefined ? { args: json(e.args) } : {}), ...(Array.isArray(e.calls) ? { calls: json(e.calls) } : {}) });
|
|
146
148
|
return {
|
|
147
149
|
existing: opened.existing, generation: opened.generation, state: opened.state,
|
|
148
150
|
get revision() { return revision; },
|
|
@@ -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 { checkHostTools } 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,8 @@ 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
|
+
checkHostTools(host.tools ?? [], { reserved: [host.name ?? "agentrun", "submit"], executable: false });
|
|
39
42
|
const names = (host.tools ?? []).map((tool) => tool.name);
|
|
40
43
|
const checker = () => new WorkflowExtensionService({ allowedTools: names });
|
|
41
44
|
/** Inspect's checks, after naming any tool the workflow uses (a call's, or an agent node's) that the host did not
|
|
@@ -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
|
|
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
|
-
|
|
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 { checkHostTools } 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';
|
|
@@ -40,6 +42,8 @@ function singleTextInput(workflow) {
|
|
|
40
42
|
}
|
|
41
43
|
const nativeLimits = Object.freeze({ deadlineMs: 600_000, modelRequests: 50, judgeCalls: 100, toolAttempts: 100,
|
|
42
44
|
nodeMaxTurns: 8, nodeMaxSubmissions: 3, nodeTimeoutMs: 120_000 });
|
|
45
|
+
/** Forgets a session's last run (its observation, report and run id), so the next run starts fresh. */
|
|
46
|
+
const clearRun = (s) => { s.observation = undefined; s.report = undefined; s.runId = undefined; };
|
|
43
47
|
/** Configure the native extension without granting configuration to workflow authors. */
|
|
44
48
|
export function createAgentRunExtension(options = {}) {
|
|
45
49
|
if (options.hostTools !== undefined && typeof options.hostTools !== 'function')
|
|
@@ -52,7 +56,8 @@ export function createAgentRunExtension(options = {}) {
|
|
|
52
56
|
throw new Error('onWorkflowEvent must be a host callback');
|
|
53
57
|
if (options.sop !== undefined && (typeof options.sop !== 'string' || !options.sop.trim()))
|
|
54
58
|
throw new Error('sop must be nonempty text');
|
|
55
|
-
|
|
59
|
+
// A field given as undefined is an omitted one: it keeps its default.
|
|
60
|
+
const limits = { ...nativeLimits, ...Object.fromEntries(Object.entries(options.runtimeLimits ?? {}).filter(([, value]) => value !== undefined)) };
|
|
56
61
|
for (const [key, value] of Object.entries(limits)) {
|
|
57
62
|
if (!(key in nativeLimits) || value !== null && (!Number.isSafeInteger(value) || value < 1 || value > nativeLimits[key])) {
|
|
58
63
|
throw new Error('runtimeLimits must use known fields with positive bounded integers or explicit null');
|
|
@@ -74,21 +79,8 @@ function registerExtension(pi, configuration) {
|
|
|
74
79
|
const toolSet = (ctx, trusted = false) => {
|
|
75
80
|
if (configuration.hostTools) {
|
|
76
81
|
const supplied = configuration.hostTools(ctx);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const names = new Set();
|
|
80
|
-
return supplied.map(tool => {
|
|
81
|
-
if (!tool || typeof tool.name !== 'string' || !tool.name.trim() || tool.name.length > 200
|
|
82
|
-
|| ['agentrun', 'submit'].includes(tool.name) || names.has(tool.name))
|
|
83
|
-
throw new Error('Host tools contain an invalid, reserved or duplicate name');
|
|
84
|
-
if (typeof tool.execute !== 'function' || !tool.parameters || typeof tool.parameters !== 'object')
|
|
85
|
-
throw new Error('Host tools require an executable definition and parameter schema');
|
|
86
|
-
Compile(tool.parameters);
|
|
87
|
-
if (tool.resultSchema)
|
|
88
|
-
Compile(tool.resultSchema);
|
|
89
|
-
names.add(tool.name);
|
|
90
|
-
return { ...tool, parameters: structuredClone(tool.parameters), ...(tool.resultSchema ? { resultSchema: structuredClone(tool.resultSchema) } : {}) };
|
|
91
|
-
});
|
|
82
|
+
checkHostTools(supplied, { reserved: ['agentrun', 'submit'], executable: true });
|
|
83
|
+
return supplied.map(tool => ({ ...tool, parameters: structuredClone(tool.parameters), ...(tool.resultSchema ? { resultSchema: structuredClone(tool.resultSchema) } : {}) }));
|
|
92
84
|
}
|
|
93
85
|
const active = new Set(pi.getActiveTools());
|
|
94
86
|
const builtins = new Set(pi.getAllTools().filter(t => t.sourceInfo.source === 'builtin').map(t => t.name));
|
|
@@ -188,15 +180,10 @@ function registerExtension(pi, configuration) {
|
|
|
188
180
|
if (changed) {
|
|
189
181
|
s.demo = undefined;
|
|
190
182
|
s.savedName = undefined;
|
|
191
|
-
s
|
|
192
|
-
s.report = undefined;
|
|
193
|
-
s.runId = undefined;
|
|
194
|
-
}
|
|
195
|
-
if (input !== undefined) {
|
|
196
|
-
s.observation = undefined;
|
|
197
|
-
s.report = undefined;
|
|
198
|
-
s.runId = undefined;
|
|
183
|
+
clearRun(s);
|
|
199
184
|
}
|
|
185
|
+
if (input !== undefined)
|
|
186
|
+
clearRun(s);
|
|
200
187
|
s.input = nextInput;
|
|
201
188
|
if (workflow !== undefined || input !== undefined)
|
|
202
189
|
persist(s);
|
|
@@ -289,6 +276,8 @@ function registerExtension(pi, configuration) {
|
|
|
289
276
|
throw new ToolInputValidationError(node.label, tool.name, error.problems);
|
|
290
277
|
throw error;
|
|
291
278
|
}
|
|
279
|
+
// A call the receipts refused is an effect whose outcome is unknown: the DSL's signal, thrown, never a returned value.
|
|
280
|
+
result = effectResultOf(result, node.label);
|
|
292
281
|
return !configuration.hostTools && ['search', 'support_demo_lookup', 'support_demo_handoff'].includes(node.tool ?? '') ? result.details : { content: result.content, ...(result.details === undefined ? {} : { details: result.details }) };
|
|
293
282
|
},
|
|
294
283
|
};
|
|
@@ -382,8 +371,9 @@ function registerExtension(pi, configuration) {
|
|
|
382
371
|
persist(s, true);
|
|
383
372
|
const ordinal = ++runOrdinal;
|
|
384
373
|
let sequence = 0;
|
|
374
|
+
const runDeps = sop ? { ...deps, sop: sop.text } : deps;
|
|
385
375
|
const report = await s.service.run(s.input, {
|
|
386
|
-
deps: observation.observe(driver ? withRecovery(driver,
|
|
376
|
+
deps: observation.observe(driver ? withRecovery(driver, runDeps) : runDeps),
|
|
387
377
|
signal: driver ? AbortSignal.any([signal, driver.signal]) : signal,
|
|
388
378
|
...(configuration.onWorkflowEvent ? { onTraceEvent: (event) => configuration.onWorkflowEvent({ workflowDigest: prepared.digest, runOrdinal: ordinal, sequence: ++sequence, event }) } : {}),
|
|
389
379
|
onEvent: event => {
|
|
@@ -515,9 +505,7 @@ function registerExtension(pi, configuration) {
|
|
|
515
505
|
s.input = structuredClone(args.input ?? {});
|
|
516
506
|
s.savedName = saved.name;
|
|
517
507
|
s.demo = undefined;
|
|
518
|
-
s
|
|
519
|
-
s.observation = undefined;
|
|
520
|
-
s.runId = undefined;
|
|
508
|
+
clearRun(s);
|
|
521
509
|
persist(s);
|
|
522
510
|
const preview = view(ctx, s);
|
|
523
511
|
return textResult(modelJson({ preview: formatWorkflowView(preview), workflow: s.draft, input: s.input }), { ...result, saved, view: preview });
|
|
@@ -610,9 +598,7 @@ function registerExtension(pi, configuration) {
|
|
|
610
598
|
s.savedName = saved.name;
|
|
611
599
|
s.input = {};
|
|
612
600
|
s.demo = undefined;
|
|
613
|
-
s
|
|
614
|
-
s.observation = undefined;
|
|
615
|
-
s.runId = undefined;
|
|
601
|
+
clearRun(s);
|
|
616
602
|
persist(s);
|
|
617
603
|
show(s, `Loaded procedure. Supply new input with /agentrun input. Loading never executes it.\n\n${formatWorkflowView(view(ctx, s))}`);
|
|
618
604
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 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
|
|
3
|
+
* is not blank, not in `reserved` and not repeated, and an object parameter schema (and result schema, when given) the
|
|
4
|
+
* compiler takes. With `executable`, each tool also needs an `execute` function. Throws on the first problem.
|
|
5
|
+
*/
|
|
6
|
+
export declare function checkHostTools(tools: unknown, options: {
|
|
7
|
+
reserved: readonly string[];
|
|
8
|
+
executable: boolean;
|
|
9
|
+
}): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* 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
|
|
8
|
+
* is not blank, not in `reserved` and not repeated, and an object parameter schema (and result schema, when given) the
|
|
9
|
+
* compiler takes. With `executable`, each tool also needs an `execute` function. Throws on the first problem.
|
|
10
|
+
*/
|
|
11
|
+
export function checkHostTools(tools, options) {
|
|
12
|
+
if (!Array.isArray(tools) || tools.length > MAX_HOST_TOOLS)
|
|
13
|
+
throw new Error(`Host must supply at most ${MAX_HOST_TOOLS} explicit tool definitions`);
|
|
14
|
+
const names = new Set();
|
|
15
|
+
for (const tool of tools) {
|
|
16
|
+
if (!tool || typeof tool.name !== 'string' || !tool.name.trim() || tool.name.length > 200
|
|
17
|
+
|| options.reserved.includes(tool.name) || names.has(tool.name))
|
|
18
|
+
throw new Error('Host tools contain an invalid, reserved or duplicate name');
|
|
19
|
+
if ((options.executable && typeof tool.execute !== 'function') || !tool.parameters || typeof tool.parameters !== 'object') {
|
|
20
|
+
throw new Error(options.executable ? 'Host tools require an executable definition and parameter schema' : 'Host tools require a parameter schema');
|
|
21
|
+
}
|
|
22
|
+
Compile(tool.parameters);
|
|
23
|
+
if (tool.resultSchema)
|
|
24
|
+
Compile(tool.resultSchema);
|
|
25
|
+
names.add(tool.name);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -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
|
|
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
|
package/dist/workflow-session.js
CHANGED
|
@@ -124,7 +124,7 @@ function snapshotEntry(entry) {
|
|
|
124
124
|
return undefined;
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
-
function
|
|
127
|
+
function markInterrupted(s) {
|
|
128
128
|
if (!s.running)
|
|
129
129
|
return s;
|
|
130
130
|
s.running = false;
|
|
@@ -145,7 +145,7 @@ export function restoreWorkflowSession(branch) {
|
|
|
145
145
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
146
146
|
const snapshot = snapshotEntry(Object.getOwnPropertyDescriptor(branch, String(i))?.value);
|
|
147
147
|
if (snapshot)
|
|
148
|
-
return
|
|
148
|
+
return markInterrupted(snapshot);
|
|
149
149
|
}
|
|
150
150
|
return undefined;
|
|
151
151
|
}
|
|
@@ -162,7 +162,7 @@ export function workflowRunHistory(branch) {
|
|
|
162
162
|
if (seen.has(key))
|
|
163
163
|
continue;
|
|
164
164
|
seen.add(key);
|
|
165
|
-
results.push(
|
|
165
|
+
results.push(markInterrupted(snapshot));
|
|
166
166
|
}
|
|
167
167
|
return results;
|
|
168
168
|
}
|
package/dist/workflow-store.js
CHANGED
|
@@ -11,11 +11,11 @@ export class WorkflowStoreError extends Error {
|
|
|
11
11
|
this.name = 'WorkflowStoreError';
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
const
|
|
14
|
+
const assertValidName = (name) => {
|
|
15
15
|
if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name))
|
|
16
16
|
throw new WorkflowStoreError('invalid_name', 'Use a workflow name of 1–64 lowercase letters, digits, hyphens or underscores, starting with a letter or digit.');
|
|
17
17
|
};
|
|
18
|
-
const
|
|
18
|
+
const assertValidDigest = (digest) => {
|
|
19
19
|
if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest))
|
|
20
20
|
throw new WorkflowStoreError('invalid_revision', 'A workflow revision must be its complete SHA-256 digest.');
|
|
21
21
|
};
|
|
@@ -50,7 +50,7 @@ export class WorkflowStore {
|
|
|
50
50
|
constructor(projectDirectory) { this.projectDirectory = resolve(projectDirectory); }
|
|
51
51
|
async directory(name, create = false) {
|
|
52
52
|
if (name !== undefined)
|
|
53
|
-
|
|
53
|
+
assertValidName(name);
|
|
54
54
|
let directory = await realpath(this.projectDirectory);
|
|
55
55
|
for (const component of ['.pi', 'agentrun', 'workflows', ...(name === undefined ? [] : [name])]) {
|
|
56
56
|
directory = join(directory, component);
|
|
@@ -78,8 +78,8 @@ export class WorkflowStore {
|
|
|
78
78
|
return directory;
|
|
79
79
|
}
|
|
80
80
|
async read(name, digest) {
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
assertValidName(name);
|
|
82
|
+
assertValidDigest(digest);
|
|
83
83
|
const directory = await this.directory(name);
|
|
84
84
|
if (!directory)
|
|
85
85
|
throw new WorkflowStoreError('not_found', 'Saved workflow revision not found.');
|
|
@@ -159,7 +159,7 @@ export class WorkflowStore {
|
|
|
159
159
|
const revisions = [];
|
|
160
160
|
const latest = new Map();
|
|
161
161
|
for (const name of await readdir(directory)) {
|
|
162
|
-
|
|
162
|
+
assertValidName(name);
|
|
163
163
|
const child = await this.directory(name);
|
|
164
164
|
if (!child)
|
|
165
165
|
continue;
|
|
@@ -180,7 +180,7 @@ export class WorkflowStore {
|
|
|
180
180
|
|| Number(b.digest === latest.get(b.name)) - Number(a.digest === latest.get(a.name)) || a.digest.localeCompare(b.digest));
|
|
181
181
|
}
|
|
182
182
|
async load(name, digest) {
|
|
183
|
-
|
|
183
|
+
assertValidName(name);
|
|
184
184
|
if (digest !== undefined)
|
|
185
185
|
return this.read(name, digest);
|
|
186
186
|
const latest = await this.latest(name);
|
|
@@ -197,7 +197,7 @@ export class WorkflowStore {
|
|
|
197
197
|
}
|
|
198
198
|
/** Publish a complete immutable revision atomically; identical saves are idempotent. */
|
|
199
199
|
async save(name, value) {
|
|
200
|
-
|
|
200
|
+
assertValidName(name);
|
|
201
201
|
const { workflow, digest } = checkedWorkflow(value);
|
|
202
202
|
const directory = (await this.directory(name, true));
|
|
203
203
|
const temporary = join(directory, `.pending-${randomUUID()}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parcha/agentrun-pi",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.23",
|
|
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.
|
|
53
|
+
"@parcha/pi-durable-disk": "0.1.0-beta.23",
|
|
54
54
|
"typebox": "1.3.7"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@parcha/agentrun-dsl": "0.1.0-beta.
|
|
58
|
-
"@parcha/agentrun-jev": "0.1.0-beta.
|
|
57
|
+
"@parcha/agentrun-dsl": "0.1.0-beta.23",
|
|
58
|
+
"@parcha/agentrun-jev": "0.1.0-beta.23"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@earendil-works/pi-agent-core": "*",
|