@parcha/agentrun-pi 0.1.0-beta.14 → 0.1.0-beta.16
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 +40 -2
- package/dist/durable/index.d.ts +2 -1
- package/dist/durable/index.js +1 -0
- package/dist/durable/node.js +27 -10
- package/dist/durable/readers.d.ts +4 -1
- package/dist/durable/readers.js +44 -5
- package/dist/durable/receipts.d.ts +4 -2
- package/dist/durable/receipts.js +3 -2
- package/dist/durable/store.js +57 -9
- package/dist/durable/task.d.ts +6 -2
- package/dist/durable/task.js +18 -2
- package/dist/durable/workflow-tool.d.ts +32 -0
- package/dist/durable/workflow-tool.js +188 -0
- package/dist/extension.js +64 -32
- package/dist/host-addendum.js +3 -2
- package/dist/pi-host.d.ts +7 -0
- package/dist/pi-host.js +22 -0
- package/dist/signin/index.d.ts +9 -0
- package/dist/signin/index.js +7 -0
- package/dist/signin/limit.d.ts +10 -0
- package/dist/signin/limit.js +23 -0
- package/dist/signin/resolve.d.ts +47 -0
- package/dist/signin/resolve.js +163 -0
- package/dist/signin/signin.d.ts +44 -0
- package/dist/signin/signin.js +126 -0
- package/dist/signin/spec.d.ts +116 -0
- package/dist/signin/spec.js +181 -0
- package/dist/signin/store.d.ts +40 -0
- package/dist/signin/store.js +206 -0
- package/dist/skills/author/SKILL.md +3 -2
- package/dist/skills/author/references/language.md +4 -3
- package/dist/skills/author/references/sdk-authoring.md +2 -0
- package/dist/workflow-inspector.d.ts +1 -1
- package/dist/workflow-inspector.js +2 -2
- package/dist/workflow-store.d.ts +6 -0
- package/dist/workflow-store.js +6 -0
- package/dist/workflow-tool.d.ts +81 -0
- package/dist/workflow-tool.js +94 -0
- package/dist/workflow-view.js +5 -1
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.js +3 -0
- package/package.json +16 -4
package/README.md
CHANGED
|
@@ -74,6 +74,7 @@ Loading starts with empty input and does not execute. A save retains the definit
|
|
|
74
74
|
| `/agentrun` | Inspect the current workflow, or show help if there is none. |
|
|
75
75
|
| `/agentrun input <case or JSON>` | Set new input without changing the definition or running it. |
|
|
76
76
|
| `/agentrun run` | Repeat the current workflow with its last supplied input. Scripted demos stay scripted; authored workflows use real adapters. |
|
|
77
|
+
| `/agentrun dry-run` | Run the current workflow on synthetic values, with no model, Jev or tool call. |
|
|
77
78
|
| `/agentrun save <name>` | Save an immutable, project-local definition revision. |
|
|
78
79
|
| `/agentrun load <name> [digest]` | Load the newest or exact revision; input starts empty. |
|
|
79
80
|
| `/agentrun list` | List saved revisions. |
|
|
@@ -89,6 +90,19 @@ A live run is durable. Its journal is kept in `.pi/agentrun/runs/<run id>/journa
|
|
|
89
90
|
|
|
90
91
|
One workflow runs at a time. Named definitions are durable across sessions in the same project. Pi run receipts belong to the current branch and may remain in memory until Pi persists its session; saving a definition does not save a receipt.
|
|
91
92
|
|
|
93
|
+
A host that does not run the extension can save, load and list the same definitions with `@parcha/agentrun-pi/workflows`, which loads nothing from pi:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { WorkflowStore } from '@parcha/agentrun-pi/workflows';
|
|
97
|
+
|
|
98
|
+
const store = new WorkflowStore(projectDirectory); // records go to <projectDirectory>/.pi/agentrun/workflows/<name>/
|
|
99
|
+
const saved = await store.save('release-review', workflow); // an immutable revision, named by its SHA-256
|
|
100
|
+
const again = await store.load('release-review'); // the last one saved; pass a digest for an exact revision
|
|
101
|
+
const revisions = await store.list(); // every revision, newest first
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
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
|
+
|
|
92
106
|
### Three demo modes
|
|
93
107
|
|
|
94
108
|
| Command | What it demonstrates |
|
|
@@ -105,6 +119,8 @@ By default, the extension exposes currently active Pi built-in `read`, `grep`, `
|
|
|
105
119
|
|
|
106
120
|
The user must issue `/agentrun run --trusted` for each run that needs code nodes or active built-in `bash`, `edit`, or `write` tools. This permits local, unsandboxed execution; authorization does not carry into the next run. A model cannot grant itself that permission with a tool argument.
|
|
107
121
|
|
|
122
|
+
A dry run (`/agentrun dry-run`, the inspector's Dry-run action, or the tool's `dry-run` action, which also takes an `input`) runs the inspected workflow on synthetic values: agent and judge answers and tool results are synthesized from their schemas, so it makes no model, Jev or tool call, writes no run journal and leaves the last run's receipt as it was. Its `status` is `passed`, `failed` (`problems` say why), or `not_run`: a check could not run, for example a schema the generator cannot satisfy (`pattern`, `format`), a synthetic answer that escalated, or SOP text that was not supplied, and `skipped` says which. A `not_run` dry run proves nothing. A workflow with code nodes is `not_run` unless the user issues `/agentrun dry-run --trusted`, which runs its code in this process for that dry run only; a code node that throws is reported by its label, without what it threw. A dry run stops after 10,000 synthetic steps, or at `/agentrun stop`, as `not_run`.
|
|
123
|
+
|
|
108
124
|
A workflow that names `sopSection`s needs SOP text. `/agentrun sop <file>` reads a Markdown file inside the project (up to 512 KiB) and checks that it has a `## <section>` heading for each section the workflow names; `/agentrun sop` shows what is set. The file's digest is kept with the session, and a run is refused if the file changed since it was supplied. A host that passes `sop` to `createAgentRunExtension` supplies the text itself and `/agentrun sop` is not used.
|
|
109
125
|
|
|
110
126
|
Custom extension tools and the outer Pi session's permission hooks are **not inherited**, including in trusted runs. A configured host can explicitly supply its own tool definitions as described below. Shell/executor effect transports and artifact delivery are unavailable in this extension; declared tool effects must use an available tool.
|
|
@@ -230,6 +246,28 @@ With `rubricSections`, every supplied section must appear on every generated LLM
|
|
|
230
246
|
|
|
231
247
|
The standalone CLI remains available as `node packages/pi/dist/cli.js --help` from source. It imports a trusted caller-owned `--config` module for `author` and `run`; importing that module executes its code. `validate` can evaluate code probes. Review executable workflows before using it.
|
|
232
248
|
|
|
249
|
+
## Sign in, pick a model, check keys
|
|
250
|
+
|
|
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@~1.2.0`) to use this entry. No other entry imports it.
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
|
|
255
|
+
import { checkKeys, resolveModel, usageLimitError } from '@parcha/agentrun-pi/signin';
|
|
256
|
+
|
|
257
|
+
const report = checkKeys(spec, { live: true, dir: demoDir });
|
|
258
|
+
if (!report.ok) { console.error(report.text); process.exit(report.exitCode); } // 2: nothing was sent
|
|
259
|
+
const { model, getApiKey, source } = await resolveModel(spec, { provider, model: modelId });
|
|
260
|
+
const message = await builtinModels().completeSimple(model, context, { apiKey: await getApiKey() });
|
|
261
|
+
if (usageLimitError(message)) throw usageLimitError(message);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- `checkKeys(spec, options)` lists what this run needs and what is missing: each key by its env name and where to create it, each program on `PATH` (refused outside its `platforms`, or below its `min` version as the first x.y.z that `<cmd> --version` prints), and each package as the demo's directory resolves it, with the exact `npm install --no-save <name>@<version>` line when it is missing. An item with `for: 'live'` or `for: 'kill'` counts only in that mode. A `oneOf` choice is met by any member, and an array member only by all of its items. A Jev judge asks for `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY`. The check sends no request and prompts for nothing; a key's value is never printed.
|
|
265
|
+
- `checkSetupPrompt(spec, text)` checks a demo's setup prompt against its spec: every key env name, program command and package name in the spec appears in it, and it names no credential env name (`*_API_KEY`, `*_KEY`, `*_TOKEN*`, `*_SECRET*`, `*_PROJECT_ID`) the spec does not list. `node scripts/check-setup-prompts.mjs` runs it for every `examples/*/demo.json` in CI.
|
|
266
|
+
- `resolveModel(spec, options)` picks the model and its key. An explicit `provider` (and `model`) with that provider's env key wins, passed on as an explicit key; Anthropic takes `ANTHROPIC_API_KEY` only. Otherwise a stored ChatGPT sign-in is used, and the model is chosen from the account's own `GET /v1/models` by what the spec needs (text, tools, images): the spec's default when the account lists it, else the newest one pi-ai knows. With neither, it runs the sign-in. A sign-in's model takes only streaming Responses calls with `store: false` (`responsesOnly`, `unsupported`). The spec's eyes model resolves the same way: an explicit `eyes` model with its key, else the lead model when it meets the eyes' needs, else a `ResolveError` naming the gap; the ChatGPT plan takes no video input. The result serializes without its key.
|
|
267
|
+
- `signIn(options)` runs pi-ai's own Sign in with ChatGPT once per install, with this app's name and a host id kept for the install. pi-ai's callback port is fixed at 1455; when another login holds it, the sign-in fails at once and says to finish or cancel that login. A pi-ai that ignores the app name (before 1.1) is refused before the page opens.
|
|
268
|
+
- The sign-in lives in `$AGENTRUN_HOME/auth.json` (default `~/.config/agentrun/`): the directory is 0700 and the file 0600, written whole and renamed into place. An existing directory others can read is refused, not changed. Refreshes are serialized across processes by an operating-system lock, an exclusive SQLite transaction on `auth.json.lock` (an empty database beside it, through `node:sqlite`). The system frees it when its holder exits or is killed, so a crashed run leaves no lock to clear, the rotating refresh token is never sent twice, and a process that waited reads the new token. On Node 22, `node:sqlite` prints one experimental warning when the first write loads it. A token never reaches a log, a URL or a recording.
|
|
269
|
+
- The plan's usage limit (`subscription_sharing_usage_limit_exceeded`) maps to `UsageLimitError`: "Your ChatGPT plan's usage limit is reached; wait, or set a provider key." It is never retried.
|
|
270
|
+
|
|
233
271
|
## Develop from source
|
|
234
272
|
|
|
235
273
|
To change the extension itself, clone and build the repository:
|
|
@@ -250,9 +288,9 @@ These commands create a separate `agentrun-pi-project` beside the checkout. You
|
|
|
250
288
|
|
|
251
289
|
## Dependencies and retained data
|
|
252
290
|
|
|
253
|
-
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 `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.
|
|
291
|
+
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.
|
|
254
292
|
|
|
255
|
-
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.2.0 @earendil-works/pi-agent-core@~1.2.0 @earendil-works/pi-tui@~1.2.0 typebox`). The pi packages are pinned to 1.2: pi 1.3.0 pins its own packages to exact versions, and this release's ranges stop below 1.3 until the next one. The tested host is Pi 1.2.0; peer ranges at `*` do not make an older or newer host compatible. 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.
|
|
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@~1.2.0 @earendil-works/pi-agent-core@~1.2.0 @earendil-works/pi-tui@~1.2.0 typebox`). The pi packages are pinned to 1.2: pi 1.3.0 pins its own packages to exact versions, and this release's ranges stop below 1.3 until the next one. The tested host is Pi 1.2.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.
|
|
256
294
|
|
|
257
295
|
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.
|
|
258
296
|
|
package/dist/durable/index.d.ts
CHANGED
|
@@ -16,4 +16,5 @@ export { closeNodeAttempt, hostScope, NodeBindingMismatch, NodeIndex, runNodeAtt
|
|
|
16
16
|
export type { NodeAttempt, NodeOutcome, NodeScope, NodeSettlement } from "./node.js";
|
|
17
17
|
export { NodeDoc, NodeFailure, nodeRunner } from "./node-runner.js";
|
|
18
18
|
export type { NodeHost, NodeRecord, NodeRequest } from "./node-runner.js";
|
|
19
|
-
export { createWorkflowExtension, type WorkflowEnding, type WorkflowHostParts, type WorkflowInvocation, type WorkflowOutcome, type WorkflowRequest, type WorkflowRun, type WorkflowStop, type WorkflowTaskHost } from "./task.js";
|
|
19
|
+
export { createWorkflowExtension, type WorkflowCaller, type WorkflowEnding, type WorkflowHostParts, type WorkflowInvocation, type WorkflowOutcome, type WorkflowRequest, type WorkflowRun, type WorkflowStop, type WorkflowTaskHost } from "./task.js";
|
|
20
|
+
export { createWorkflowTool, DURABLE_HOST_ADDENDUM, type WorkflowToolHost } from "./workflow-tool.js";
|
package/dist/durable/index.js
CHANGED
|
@@ -11,3 +11,4 @@ export { deliveredRecord, GateDoc, RecordDoc, recordNudge, recordStore, recordTo
|
|
|
11
11
|
export { closeNodeAttempt, hostScope, NodeBindingMismatch, NodeIndex, runNodeAttempt, taskScope } from "./node.js";
|
|
12
12
|
export { NodeDoc, NodeFailure, nodeRunner } from "./node-runner.js";
|
|
13
13
|
export { createWorkflowExtension } from "./task.js";
|
|
14
|
+
export { createWorkflowTool, DURABLE_HOST_ADDENDUM } from "./workflow-tool.js";
|
package/dist/durable/node.js
CHANGED
|
@@ -39,6 +39,19 @@ export class NodeBindingMismatch extends Error {
|
|
|
39
39
|
/** Run one attempt. */
|
|
40
40
|
export async function runNodeAttempt(scope, attempt, context) {
|
|
41
41
|
let found;
|
|
42
|
+
/** The attempt's conversation when another task owns it: that task ended, and this one replaced it. */
|
|
43
|
+
let orphaned;
|
|
44
|
+
/** A new conversation for the attempt, owned by the scope's task, recorded under the attempt's session. */
|
|
45
|
+
const create = async (tx, entry) => {
|
|
46
|
+
const conversation = await tx.createConversation({ ownership: scope.owner !== undefined ? { kind: "task", taskId: scope.owner } : { kind: "ownerless" } });
|
|
47
|
+
await configure(tx, conversation.id, attempt.agent);
|
|
48
|
+
await attempt.init?.(tx, conversation.id);
|
|
49
|
+
entry.conversation = Number(conversation.id);
|
|
50
|
+
entry.digest = attempt.digest;
|
|
51
|
+
entry.startedMs = Date.now();
|
|
52
|
+
entry.closed = false;
|
|
53
|
+
found = { conversationId: conversation.id, digest: attempt.digest, resumed: false, startedMs: entry.startedMs };
|
|
54
|
+
};
|
|
42
55
|
await scope.commit(async (tx) => {
|
|
43
56
|
const entry = await tx.doc(NodeIndex, attempt.session, null);
|
|
44
57
|
if (entry.conversation !== null) {
|
|
@@ -46,19 +59,23 @@ export async function runNodeAttempt(scope, attempt, context) {
|
|
|
46
59
|
// An attempt under another binding is refused below, untouched.
|
|
47
60
|
if (entry.digest === attempt.digest)
|
|
48
61
|
await attempt.adopt?.(tx, found.conversationId, entry);
|
|
62
|
+
if (scope.owner !== undefined && entry.digest === attempt.digest) {
|
|
63
|
+
const owner = (await tx.conversation(found.conversationId))?.owner?.taskId;
|
|
64
|
+
if (owner !== undefined && owner !== scope.owner)
|
|
65
|
+
orphaned = found.conversationId;
|
|
66
|
+
}
|
|
49
67
|
return;
|
|
50
68
|
}
|
|
51
|
-
|
|
52
|
-
await configure(tx, conversation.id, attempt.agent);
|
|
53
|
-
await attempt.init?.(tx, conversation.id);
|
|
54
|
-
entry.conversation = Number(conversation.id);
|
|
55
|
-
entry.digest = attempt.digest;
|
|
56
|
-
entry.startedMs = Date.now();
|
|
57
|
-
found = { conversationId: conversation.id, digest: attempt.digest, resumed: false, startedMs: entry.startedMs };
|
|
69
|
+
await create(tx, entry);
|
|
58
70
|
}, context);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
if (found.digest !== attempt.digest)
|
|
72
|
+
throw new NodeBindingMismatch(attempt.session, found.digest, attempt.digest);
|
|
73
|
+
// The replaced task's conversation: its record, when it delivered one, is the node's; otherwise the attempt runs
|
|
74
|
+
// again in a conversation of this task, never on the request the old task's end aborted.
|
|
75
|
+
if (orphaned !== undefined && !(await deliveredRecord(scope, orphaned, context))) {
|
|
76
|
+
await scope.commit(async (tx) => { await create(tx, await tx.doc(NodeIndex, attempt.session, null)); }, context);
|
|
77
|
+
}
|
|
78
|
+
const { conversationId, resumed, startedMs } = found;
|
|
62
79
|
await attempt.onOpen?.({ conversationId, resumed, startedMs });
|
|
63
80
|
// A record the attempt already delivered is the node's record: nothing is requested for it.
|
|
64
81
|
const already = await deliveredRecord(scope, conversationId, context);
|
|
@@ -17,7 +17,10 @@ export declare function withDurableFile<T>(file: string, read: (db: Database) =>
|
|
|
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
19
|
/** The journal `key` holds in the run's store `file`, as the last commit left it; undefined when the file holds no
|
|
20
|
-
* such journal or none has bound it.
|
|
20
|
+
* such journal or none has bound it. `effects` are every effect the journal admitted, once each, in the order they were
|
|
21
|
+
* admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
|
|
22
|
+
* included, are listed by that item's record, not the driver's, and are read from there. `state` is the driver's own
|
|
23
|
+
* record: in the keyed layout what lies in each map item stays in the item's record. */
|
|
21
24
|
export declare function readJournal(file: string, key: string): JournalView | undefined;
|
|
22
25
|
/** Every committed route decision a frozen snapshot holds (each judge's answer, whole), by execution path. Empty for a
|
|
23
26
|
* state that is no snapshot. */
|
package/dist/durable/readers.js
CHANGED
|
@@ -32,24 +32,63 @@ function revisionsOf(db, id) {
|
|
|
32
32
|
}
|
|
33
33
|
return value;
|
|
34
34
|
}
|
|
35
|
-
/**
|
|
36
|
-
function
|
|
35
|
+
/** The row of a Session document. `key` names a family member; a singleton has none. */
|
|
36
|
+
function sessionDocId(db, kind, key) {
|
|
37
37
|
const row = db.prepare("SELECT id FROM documents WHERE kind = ? AND scope_kind = 'session' AND owner_id = 0 AND family = ? AND key_value = ? AND retired_at IS NULL ORDER BY created_at DESC LIMIT 1")
|
|
38
38
|
.get(JSON.stringify(kind), key === undefined ? 0 : 1, JSON.stringify(key ?? ""));
|
|
39
|
-
return row?.id
|
|
39
|
+
return row?.id;
|
|
40
|
+
}
|
|
41
|
+
/** A Session document's current value. `key` names a family member; a singleton has none. */
|
|
42
|
+
function sessionDoc(db, kind, key) {
|
|
43
|
+
const id = sessionDocId(db, kind, key);
|
|
44
|
+
return id === undefined ? undefined : revisionsOf(db, id);
|
|
45
|
+
}
|
|
46
|
+
/** The ids of the effects the map items of `table` own (the keyed layout's `maps`: `{ <map path>: { items, ... } }`), each
|
|
47
|
+
* item's own nested maps included, read as the driver reads them back: item `i` of a map is its record at
|
|
48
|
+
* `<map path>/items/<i>`, for every item the map started. */
|
|
49
|
+
function itemEffects(db, key, table, into) {
|
|
50
|
+
if (!table || typeof table !== "object" || Array.isArray(table))
|
|
51
|
+
return;
|
|
52
|
+
for (const [mapPath, entry] of Object.entries(table)) {
|
|
53
|
+
const started = entry?.items;
|
|
54
|
+
if (typeof started !== "number" || !Number.isSafeInteger(started))
|
|
55
|
+
continue;
|
|
56
|
+
for (let index = 0; index < started; index += 1) {
|
|
57
|
+
const item = sessionDoc(db, "agentrun.items", effectKey(key, `${mapPath}/items/${index}`));
|
|
58
|
+
if (!item)
|
|
59
|
+
continue;
|
|
60
|
+
if (Array.isArray(item.effects))
|
|
61
|
+
for (const id of item.effects)
|
|
62
|
+
if (typeof id === "string")
|
|
63
|
+
into.push(id);
|
|
64
|
+
itemEffects(db, key, item.maps, into);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
40
67
|
}
|
|
41
68
|
/** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
|
|
42
69
|
export function durableDoc(file, kind, key) {
|
|
43
70
|
return (withDurableFile(file, (db) => sessionDoc(db, kind, key)) ?? undefined);
|
|
44
71
|
}
|
|
45
72
|
/** The journal `key` holds in the run's store `file`, as the last commit left it; undefined when the file holds no
|
|
46
|
-
* such journal or none has bound it.
|
|
73
|
+
* such journal or none has bound it. `effects` are every effect the journal admitted, once each, in the order they were
|
|
74
|
+
* admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
|
|
75
|
+
* included, are listed by that item's record, not the driver's, and are read from there. `state` is the driver's own
|
|
76
|
+
* record: in the keyed layout what lies in each map item stays in the item's record. */
|
|
47
77
|
export function readJournal(file, key) {
|
|
48
78
|
return withDurableFile(file, (db) => {
|
|
49
79
|
const doc = sessionDoc(db, "agentrun.driver", key);
|
|
50
80
|
if (!doc || doc.binding === null)
|
|
51
81
|
return undefined;
|
|
52
|
-
|
|
82
|
+
// The driver's record lists the effects no map item owns; in the keyed layout each item's record lists its own. All of
|
|
83
|
+
// them, once each, in the order they were admitted: an effect's document is created by its admission, and a document's
|
|
84
|
+
// row is numbered when it is created (a later checkpoint folds its revisions, never its row).
|
|
85
|
+
const ids = [...doc.effects];
|
|
86
|
+
itemEffects(db, key, doc.state?.maps, ids);
|
|
87
|
+
const admitted = new Map();
|
|
88
|
+
for (const id of new Set(ids))
|
|
89
|
+
admitted.set(id, sessionDocId(db, "agentrun.effects", effectKey(key, id)) ?? Number.MAX_SAFE_INTEGER);
|
|
90
|
+
// A stable sort: an id whose document is missing keeps its place in the listing, after every admitted one.
|
|
91
|
+
const effects = [...admitted.keys()].sort((a, b) => admitted.get(a) - admitted.get(b)).map((id) => {
|
|
53
92
|
const e = sessionDoc(db, "agentrun.effects", effectKey(key, id));
|
|
54
93
|
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 } : {}) };
|
|
55
94
|
});
|
|
@@ -25,8 +25,10 @@ export type ReusableTool = {
|
|
|
25
25
|
export type ReuseReceiptsOptions = {
|
|
26
26
|
/** The tools whose calls are paid and keep a receipt: a tool whose answer is its own session's state keeps none. */
|
|
27
27
|
tools: readonly string[];
|
|
28
|
-
/** The session
|
|
29
|
-
|
|
28
|
+
/** The session a call belongs to: a call is never answered from a receipt its own session wrote. Absent, each call's
|
|
29
|
+
* own conversation, so a node run again in another conversation (its task replaced after it failed) is answered from
|
|
30
|
+
* the receipt the conversation it replaces paid for, and does not pay again. */
|
|
31
|
+
sessionId?: string;
|
|
30
32
|
/** Whether a repeat of another session's paid call is answered from its receipt (a continuation's sessions do). Receipts
|
|
31
33
|
* are recorded either way. */
|
|
32
34
|
answer: boolean;
|
package/dist/durable/receipts.js
CHANGED
|
@@ -62,9 +62,10 @@ export function reuseReceipts(options) {
|
|
|
62
62
|
return toolResult([{ type: "text", text: `${tool.name} is not called again: an identical earlier call was cut while it ran, so what it did outside is unknown. Work from what you have, or make a different call.` }], { isError: true, details: { effect_status: "unknown", refused: true } });
|
|
63
63
|
}
|
|
64
64
|
const key = `${intent.tool}:${intent.argsHash}`;
|
|
65
|
+
const session = options.sessionId ?? String(api?.conversationId);
|
|
65
66
|
if (options.answer) {
|
|
66
67
|
const receipt = await api.snapshot(ReceiptIndex, key, context);
|
|
67
|
-
if (receipt?.result != null && receipt.session !==
|
|
68
|
+
if (receipt?.result != null && receipt.session !== session) {
|
|
68
69
|
stats.reused += 1;
|
|
69
70
|
const text = typeof receipt.result === "string" ? receipt.result : JSON.stringify(receipt.result);
|
|
70
71
|
return toolResult([{ type: "text", text: `${text}\n\n(answered from the paid receipt of ${receipt.session}; the call was not dispatched again)` }], { details: { effect_status: "reused", source_session: receipt.session } });
|
|
@@ -80,7 +81,7 @@ export function reuseReceipts(options) {
|
|
|
80
81
|
receipt.tool = intent.tool;
|
|
81
82
|
receipt.argsHash = intent.argsHash;
|
|
82
83
|
receipt.result = value;
|
|
83
|
-
receipt.session =
|
|
84
|
+
receipt.session = session;
|
|
84
85
|
}
|
|
85
86
|
}, context);
|
|
86
87
|
}
|
package/dist/durable/store.js
CHANGED
|
@@ -25,6 +25,19 @@ const DriverDoc = defineDocFamily({ kind: "agentrun.driver", version: 1, scope:
|
|
|
25
25
|
initial: () => ({ binding: null, inputs: null, generation: 0, revision: 0, state: null, notes: [], effects: [] }) });
|
|
26
26
|
const EffectDoc = defineDocFamily({ kind: "agentrun.effects", version: 1, scope: "session", family: true,
|
|
27
27
|
initial: () => ({ driver: "", name: "", argsHash: "", status: "unknown", session: null, result: null }) });
|
|
28
|
+
const ItemDoc = defineDocFamily({ kind: "agentrun.items", version: 1, scope: "session", family: true,
|
|
29
|
+
initial: () => ({ driver: "", effects: [] }) });
|
|
30
|
+
/** A state by its digest; `sha256` is empty only for one never written. */
|
|
31
|
+
const StateDoc = defineDocFamily({ kind: "agentrun.states", version: 1, scope: "session", family: true,
|
|
32
|
+
initial: () => ({ driver: "", sha256: "", state: null }) });
|
|
33
|
+
/** A draft made to hold `value` whole: every field set, every other field removed. */
|
|
34
|
+
function replaceWhole(draft, value) {
|
|
35
|
+
for (const field of Object.keys(draft))
|
|
36
|
+
if (!Object.hasOwn(value, field))
|
|
37
|
+
delete draft[field];
|
|
38
|
+
for (const [field, item] of Object.entries(value))
|
|
39
|
+
draft[field] = item;
|
|
40
|
+
}
|
|
28
41
|
/** A value as the journal stores it: plain JSON, every number finite. */
|
|
29
42
|
function json(value) {
|
|
30
43
|
return value === undefined ? null : JSON.parse(JSON.stringify(value, (_key, item) => {
|
|
@@ -107,23 +120,36 @@ async function openDocumentJournal(host, key, bound, options) {
|
|
|
107
120
|
return done;
|
|
108
121
|
};
|
|
109
122
|
/** One Session commit on the journal's record: it checks this open's generation is the record's, advances the
|
|
110
|
-
* revision, and lets `write` change the record (its state computed with that revision) and the effect documents
|
|
111
|
-
|
|
123
|
+
* revision, and lets `write` change the record (its state computed with that revision) and the effect documents,
|
|
124
|
+
* then writes the parts computed with that revision. */
|
|
125
|
+
const commit = async (write, parts) => host.commit(async (tx) => {
|
|
112
126
|
const doc = await tx.doc(DriverDoc, key, null);
|
|
113
127
|
if (doc.generation !== opened.generation)
|
|
114
128
|
throw new RecoveryError("Run owner generation is not acquired");
|
|
115
129
|
const next = doc.revision + 1;
|
|
116
130
|
const value = await write(doc, next, tx);
|
|
131
|
+
const laid = parts?.(next);
|
|
132
|
+
for (const [path, item] of Object.entries(laid?.items ?? {})) {
|
|
133
|
+
replaceWhole(await tx.doc(ItemDoc, effectKey(key, path), null), json(item));
|
|
134
|
+
}
|
|
135
|
+
for (const [sha256, state] of Object.entries(laid?.states ?? {})) {
|
|
136
|
+
const held = await tx.doc(StateDoc, effectKey(key, sha256), null);
|
|
137
|
+
if (held.sha256 === "")
|
|
138
|
+
Object.assign(held, { driver: key, sha256, state: json(state) });
|
|
139
|
+
}
|
|
117
140
|
doc.revision = next;
|
|
118
141
|
return { revision: next, value };
|
|
119
142
|
}, context).then((done) => { revision = done.revision; return done; });
|
|
143
|
+
/** A read in a commit of its own: it writes nothing the caller did not ask for. */
|
|
144
|
+
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) } : {}) });
|
|
120
146
|
return {
|
|
121
147
|
existing: opened.existing, generation: opened.generation, state: opened.state,
|
|
122
148
|
get revision() { return revision; },
|
|
123
149
|
effects: () => [...effects.values()],
|
|
124
150
|
effect: (id) => effects.get(id),
|
|
125
151
|
notes: () => [...notes],
|
|
126
|
-
save: (state, note) => inOrder(async () => {
|
|
152
|
+
save: (state, note, parts) => inOrder(async () => {
|
|
127
153
|
let entry;
|
|
128
154
|
const done = await commit((doc, next) => {
|
|
129
155
|
doc.state = json(typeof state === "function" ? state(next) : state);
|
|
@@ -131,7 +157,7 @@ async function openDocumentJournal(host, key, bound, options) {
|
|
|
131
157
|
entry = { revision: next, kind: note.kind, detail: json(note.detail), at: new Date().toISOString() };
|
|
132
158
|
doc.notes.push(entry);
|
|
133
159
|
}
|
|
134
|
-
});
|
|
160
|
+
}, parts);
|
|
135
161
|
if (entry)
|
|
136
162
|
notes.push(entry);
|
|
137
163
|
return done.revision;
|
|
@@ -142,7 +168,7 @@ async function openDocumentJournal(host, key, bound, options) {
|
|
|
142
168
|
notes.push({ revision: done.revision, ...entry });
|
|
143
169
|
return done.revision;
|
|
144
170
|
}),
|
|
145
|
-
admit: (id, name, argsHash, state, session = null, intent, args) => inOrder(async () => {
|
|
171
|
+
admit: (id, name, argsHash, state, session = null, intent, args, parts, owner) => inOrder(async () => {
|
|
146
172
|
effectKey(key, id);
|
|
147
173
|
const known = effects.get(id);
|
|
148
174
|
if (known)
|
|
@@ -152,13 +178,15 @@ async function openDocumentJournal(host, key, bound, options) {
|
|
|
152
178
|
const keptArgs = args === undefined ? undefined : json(args);
|
|
153
179
|
await commit(async (doc, _next, tx) => {
|
|
154
180
|
Object.assign(await tx.doc(EffectDoc, effectKey(key, id), null), { driver: key, name, argsHash, status: "unknown", session, result: null, ...(kept ? { intent: kept } : {}), ...(keptArgs !== undefined ? { args: keptArgs } : {}) });
|
|
155
|
-
|
|
181
|
+
// An effect a map item owns is listed by that item's record, written in the same commit.
|
|
182
|
+
if (owner === undefined || !parts)
|
|
183
|
+
doc.effects.push(id);
|
|
156
184
|
doc.state = stored;
|
|
157
|
-
});
|
|
185
|
+
}, parts);
|
|
158
186
|
effects.set(id, { id, name, argsHash, status: "unknown", session, result: null, ...(kept ? { intent: kept } : {}), ...(keptArgs !== undefined ? { args: keptArgs } : {}) });
|
|
159
187
|
return "new";
|
|
160
188
|
}),
|
|
161
|
-
complete: (id, result, state = opened.state) => inOrder(async () => {
|
|
189
|
+
complete: (id, result, state = opened.state, parts) => inOrder(async () => {
|
|
162
190
|
const known = effects.get(id);
|
|
163
191
|
if (!known)
|
|
164
192
|
throw new RecoveryError(`Effect ${id} completes without an admission`);
|
|
@@ -171,7 +199,7 @@ async function openDocumentJournal(host, key, bound, options) {
|
|
|
171
199
|
effect.status = "completed";
|
|
172
200
|
effect.result = value;
|
|
173
201
|
doc.state = next;
|
|
174
|
-
});
|
|
202
|
+
}, parts);
|
|
175
203
|
effects.set(id, { ...known, status: "completed", result: value });
|
|
176
204
|
}),
|
|
177
205
|
called: (id, calls) => inOrder(async () => {
|
|
@@ -188,5 +216,25 @@ async function openDocumentJournal(host, key, bound, options) {
|
|
|
188
216
|
await queue;
|
|
189
217
|
held.release();
|
|
190
218
|
},
|
|
219
|
+
// Only paths and digests the driver wrote are asked for: a read of a document never written would create it.
|
|
220
|
+
items: (paths) => read(async (tx) => {
|
|
221
|
+
const found = [];
|
|
222
|
+
for (const path of paths) {
|
|
223
|
+
const item = await tx.doc(ItemDoc, effectKey(key, path), null);
|
|
224
|
+
if (item.driver === "") {
|
|
225
|
+
found.push(undefined);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
found.push(json(item));
|
|
229
|
+
for (const id of (item.effects ?? []))
|
|
230
|
+
if (!effects.has(id))
|
|
231
|
+
effects.set(id, storedEffect(id, await tx.doc(EffectDoc, effectKey(key, id), null)));
|
|
232
|
+
}
|
|
233
|
+
return found;
|
|
234
|
+
}),
|
|
235
|
+
stateOf: (sha256) => read(async (tx) => {
|
|
236
|
+
const held = await tx.doc(StateDoc, effectKey(key, sha256), null);
|
|
237
|
+
return held.sha256 === "" ? undefined : json(held.state);
|
|
238
|
+
}),
|
|
191
239
|
};
|
|
192
240
|
}
|
package/dist/durable/task.d.ts
CHANGED
|
@@ -88,6 +88,10 @@ export type WorkflowHostParts = {
|
|
|
88
88
|
deps: WorkflowDeps;
|
|
89
89
|
interpret?: (workflow: Workflow, input: Record<string, unknown>, deps: WorkflowDeps) => ReturnType<typeof runWorkflow>;
|
|
90
90
|
};
|
|
91
|
+
/** What starting and reading a run asks of its caller: a Harness, or a tool's own `api` inside its invocation, whose
|
|
92
|
+
* commits create the run's task in the calling conversation. A caller with no `root` names the conversation in
|
|
93
|
+
* `start`'s options; one with no `abortTask` cannot start a run with a cancel. */
|
|
94
|
+
export type WorkflowCaller = Pick<Harness, "commit" | "snapshot" | "getTask" | "waitForTask"> & Partial<Pick<Harness, "root" | "resume" | "abortTask">>;
|
|
91
95
|
/** A started run. `wait` resolves with the run's outcome once its task is terminal, and rejects with the storage's error
|
|
92
96
|
* when the Session failed at one. */
|
|
93
97
|
export type WorkflowRun = {
|
|
@@ -111,7 +115,7 @@ type WorkflowResult = Extract<WorkflowOutcome, {
|
|
|
111
115
|
/** The task definition, the extension that carries it, and the host's three operations on a run. */
|
|
112
116
|
export declare function createWorkflowExtension(host: WorkflowTaskHost): {
|
|
113
117
|
extension: Extension;
|
|
114
|
-
start(harness:
|
|
118
|
+
start(harness: WorkflowCaller, request: WorkflowRequest, context: Context, options?: {
|
|
115
119
|
conversationId?: number;
|
|
116
120
|
background?: boolean;
|
|
117
121
|
stop?: {
|
|
@@ -119,7 +123,7 @@ export declare function createWorkflowExtension(host: WorkflowTaskHost): {
|
|
|
119
123
|
source?: string;
|
|
120
124
|
};
|
|
121
125
|
}): Promise<WorkflowRun>;
|
|
122
|
-
read(harness:
|
|
126
|
+
read(harness: WorkflowCaller, key: string, context: Context): Promise<{
|
|
123
127
|
taskId: TaskId<WorkflowResult>;
|
|
124
128
|
status: string;
|
|
125
129
|
outcome?: WorkflowOutcome;
|
package/dist/durable/task.js
CHANGED
|
@@ -133,6 +133,11 @@ export function createWorkflowExtension(host) {
|
|
|
133
133
|
throw error;
|
|
134
134
|
}
|
|
135
135
|
};
|
|
136
|
+
/** The invocation ends when the host closes the Harness, and the task stays pending. */
|
|
137
|
+
const untilClosed = () => new Promise((resolve) => { if (runtime.signal.aborted)
|
|
138
|
+
resolve();
|
|
139
|
+
else
|
|
140
|
+
runtime.signal.addEventListener("abort", () => resolve(), { once: true }); });
|
|
136
141
|
let opened;
|
|
137
142
|
try {
|
|
138
143
|
opened = await open(task, runtime, context);
|
|
@@ -140,6 +145,13 @@ export function createWorkflowExtension(host) {
|
|
|
140
145
|
catch (error) {
|
|
141
146
|
if (runtime.signal.aborted)
|
|
142
147
|
throw error;
|
|
148
|
+
// A run a later build stored in a schema this build does not know is left for that build: the task stays
|
|
149
|
+
// pending, and this process's waiters get the refusal. Any other failure to open ends the run.
|
|
150
|
+
if (codeOf(error) === "RECOVERY_SCHEMA_NEWER") {
|
|
151
|
+
uncommittedOf(key, task.id).fail(error);
|
|
152
|
+
await untilClosed();
|
|
153
|
+
throw runtime.signal.reason;
|
|
154
|
+
}
|
|
143
155
|
await commitEnd(failed(error), error);
|
|
144
156
|
return;
|
|
145
157
|
}
|
|
@@ -316,7 +328,11 @@ export function createWorkflowExtension(host) {
|
|
|
316
328
|
* step. So a pause given with a start that attaches to a run already running in this process is not taken now: it
|
|
317
329
|
* is recorded, and taken when the run's next invocation opens. `stop` pauses a run that is running here. */
|
|
318
330
|
async start(harness, request, context, options = {}) {
|
|
319
|
-
|
|
331
|
+
if (options.stop?.action === "cancel" && !harness.abortTask)
|
|
332
|
+
throw new Error("A start with a cancel needs a caller that can abort a task: the Harness");
|
|
333
|
+
const conversationId = options.conversationId ?? (harness.root ? (await harness.root(context)).id : undefined);
|
|
334
|
+
if (conversationId === undefined)
|
|
335
|
+
throw new Error("A caller with no root conversation names the conversation the run belongs to: options.conversationId");
|
|
320
336
|
const stop = options.stop ? { action: options.stop.action, source: options.stop.source ?? null } : null;
|
|
321
337
|
const asked = json(request);
|
|
322
338
|
const [taskId, started] = await harness.commit(async (tx) => {
|
|
@@ -348,7 +364,7 @@ export function createWorkflowExtension(host) {
|
|
|
348
364
|
uncommitted.delete(earlier);
|
|
349
365
|
if (stop?.action === "cancel" && started !== "settled")
|
|
350
366
|
await harness.abortTask(taskId, context);
|
|
351
|
-
harness.resume();
|
|
367
|
+
harness.resume?.();
|
|
352
368
|
return handle(harness, request.key, taskId, started);
|
|
353
369
|
},
|
|
354
370
|
/** The run's task and, once it is terminal, its outcome; undefined when no start named the key. */
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Extension } from "@earendil-works/pi-durable";
|
|
2
|
+
import { type AuthorHostAddendum, type WorkflowDeps } from "@parcha/agentrun-dsl";
|
|
3
|
+
import { createWorkflowExtension, type WorkflowTaskHost } from "./task.js";
|
|
4
|
+
/** What a pi-durable session adds to the workflow language, rendered for describe's `authoring.host`. */
|
|
5
|
+
export declare const DURABLE_HOST_ADDENDUM: AuthorHostAddendum;
|
|
6
|
+
export type WorkflowToolHost = {
|
|
7
|
+
/** The project whose saved workflows save, load and list keep: `<projectDir>/.pi/agentrun/workflows`, the store the
|
|
8
|
+
* coding-agent extension keeps for its working directory. */
|
|
9
|
+
projectDir: string;
|
|
10
|
+
/** The tools a workflow may call, as describe lists them. Inspect admits only these names. */
|
|
11
|
+
tools?: readonly {
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
parameters: unknown;
|
|
15
|
+
resultSchema?: unknown;
|
|
16
|
+
}[];
|
|
17
|
+
/** What a run's task runs with: `createWorkflowExtension`'s host. */
|
|
18
|
+
task: WorkflowTaskHost;
|
|
19
|
+
/** The host's code executor. Without it no code node runs: a dry run of a draft with code is not_run, and a run of
|
|
20
|
+
* one is refused before it starts. Given, a run's task runs its code nodes through it. */
|
|
21
|
+
runCode?: WorkflowDeps["runCode"];
|
|
22
|
+
/** The SOP text for workflows that name sections. */
|
|
23
|
+
sop?: string;
|
|
24
|
+
/** The tool's name, `agentrun` by default: the agent definition lists it. */
|
|
25
|
+
name?: string;
|
|
26
|
+
};
|
|
27
|
+
/** The tool, and the workflow task its runs are: install `extension` (it carries both) and list the tool's name in the
|
|
28
|
+
* agent definition. `workflow` reads and stops runs from the host's side. */
|
|
29
|
+
export declare function createWorkflowTool(host: WorkflowToolHost): {
|
|
30
|
+
extension: Extension;
|
|
31
|
+
workflow: ReturnType<typeof createWorkflowExtension>;
|
|
32
|
+
};
|