@parcha/agentrun-pi 0.1.0-beta.12 → 0.1.0-beta.14
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 +20 -8
- package/dist/cli.js +10 -1
- package/dist/demo-cli.d.ts +2 -0
- package/dist/demo-cli.js +25 -0
- package/dist/durable/decision-recovery.d.ts +23 -0
- package/dist/durable/decision-recovery.js +57 -0
- package/dist/durable/decisions.d.ts +113 -0
- package/dist/durable/decisions.js +71 -0
- package/dist/durable/index.d.ts +19 -0
- package/dist/durable/index.js +13 -0
- package/dist/durable/node-runner.d.ts +100 -0
- package/dist/durable/node-runner.js +243 -0
- package/dist/durable/node.d.ts +88 -0
- package/dist/durable/node.js +81 -0
- package/dist/durable/pi-versions.d.ts +34 -0
- package/dist/durable/pi-versions.js +66 -0
- package/dist/durable/readers.d.ts +25 -0
- package/dist/durable/readers.js +64 -0
- package/dist/durable/receipts.d.ts +52 -0
- package/dist/durable/receipts.js +91 -0
- package/dist/durable/record-file.d.ts +10 -0
- package/dist/durable/record-file.js +56 -0
- package/dist/durable/record-tool.d.ts +76 -0
- package/dist/durable/record-tool.js +123 -0
- package/dist/durable/record.d.ts +157 -0
- package/dist/durable/record.js +402 -0
- package/dist/durable/recording.d.ts +90 -0
- package/dist/durable/recording.js +292 -0
- package/dist/durable/store.d.ts +23 -0
- package/dist/durable/store.js +192 -0
- package/dist/durable/task.d.ts +129 -0
- package/dist/durable/task.js +395 -0
- package/dist/extension-service.js +14 -1
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +98 -13
- package/dist/host-session.js +3 -1
- package/dist/run-error.d.ts +6 -0
- package/dist/run-error.js +13 -0
- package/dist/runner.d.ts +1 -6
- package/dist/runner.js +2 -12
- package/dist/sop.d.ts +12 -0
- package/dist/sop.js +29 -0
- package/dist/tool-context.d.ts +21 -0
- package/dist/tool-context.js +13 -0
- package/dist/workflow-session.d.ts +4 -0
- package/dist/workflow-session.js +3 -2
- package/package.json +43 -8
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// A workflow's LLM nodes on pi-durable, behind `runNode`: each attempt is one conversation (node.ts), and one
|
|
2
|
+
// extension serves them all.
|
|
3
|
+
//
|
|
4
|
+
// What differs per node lives with its conversation: the agent the host chose for it, and its configuration document
|
|
5
|
+
// (its system text, and the record it owes: label, schema, file key). The `task` section renders the system text from
|
|
6
|
+
// that document. A conversation an older build opened has no such document: the runner writes it when it reaches the
|
|
7
|
+
// node, with the system text that build kept on the node's index entry, and gives the conversation its agent. `submit`
|
|
8
|
+
// is registered once with open parameters; each request presents it with the node's own label and fields, and each
|
|
9
|
+
// call is decided against the node's schema. What cannot be stored (the node's reviewers, the host's checks, a
|
|
10
|
+
// committer for the nudge) is attached when the runner reaches the node, and a request, a call or a yield that pi
|
|
11
|
+
// resumed first waits for it, so nothing of a node runs before its host observes it.
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { configure, defineDoc, defineExtension, GenerationTask, hook, section, } from "@earendil-works/pi-durable";
|
|
14
|
+
import { canonicalSha256, runStopOf } from "@parcha/agentrun-dsl/recovery";
|
|
15
|
+
import { closeNodeAttempt, runNodeAttempt } from "./node.js";
|
|
16
|
+
import { DELIVERY_ATTEMPTS, lintRecordRoot, submitFooter } from "./record.js";
|
|
17
|
+
import { workspaceRecordFile } from "./record-file.js";
|
|
18
|
+
import { RecordDoc, recordNudge, recordTool, spendNudge, submitDefinition } from "./record-tool.js";
|
|
19
|
+
/** A node's configuration: what its conversation's prompt and `submit` tool are drawn from. */
|
|
20
|
+
export const NodeDoc = defineDoc({
|
|
21
|
+
kind: "agentrun.node", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ system: "", label: "", schema: null, fileKey: null }),
|
|
22
|
+
});
|
|
23
|
+
/** A node that delivered no record. `final` says a second attempt would spend the same again: the model did not
|
|
24
|
+
* answer, or the delivery attempts are spent. `unanswered` is pi's own account of a submission the model did not
|
|
25
|
+
* answer: its reason (`failed`, `aborted`, ...) and detail. */
|
|
26
|
+
export class NodeFailure extends Error {
|
|
27
|
+
node;
|
|
28
|
+
final;
|
|
29
|
+
executionPath;
|
|
30
|
+
unanswered;
|
|
31
|
+
code = "NODE_NOT_DELIVERED";
|
|
32
|
+
constructor(message, node, final, executionPath, unanswered) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.node = node;
|
|
35
|
+
this.final = final;
|
|
36
|
+
this.executionPath = executionPath;
|
|
37
|
+
this.unanswered = unanswered;
|
|
38
|
+
this.name = "NodeFailure";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** `work` under a run's stop: its result, or the stop's reason as soon as the stop lands, and at once when it already
|
|
42
|
+
* has. The listener lives only as long as the wait. */
|
|
43
|
+
async function underStop(signal, work) {
|
|
44
|
+
if (!signal)
|
|
45
|
+
return work();
|
|
46
|
+
let onStop;
|
|
47
|
+
const stopped = new Promise((_, reject) => { onStop = () => reject(signal.reason); });
|
|
48
|
+
stopped.catch(() => undefined);
|
|
49
|
+
if (signal.aborted)
|
|
50
|
+
onStop();
|
|
51
|
+
else
|
|
52
|
+
signal.addEventListener("abort", onStop, { once: true });
|
|
53
|
+
try {
|
|
54
|
+
return await Promise.race([work(), stopped]);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
signal.removeEventListener("abort", onStop);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const listed = (value) => Array.isArray(value);
|
|
61
|
+
const isSubmit = (tool) => tool.name === "submit";
|
|
62
|
+
/** The node runner over a host. Install `extension` in the registry before the Harness opens; `on` binds the runner
|
|
63
|
+
* to where its conversations live (`taskScope`, `hostScope`). */
|
|
64
|
+
export function nodeRunner(host, options = {}) {
|
|
65
|
+
const maxAttempts = host.maxAttempts ?? DELIVERY_ATTEMPTS;
|
|
66
|
+
const attached = new Map();
|
|
67
|
+
const waiting = new Map();
|
|
68
|
+
/** What the runner attached to a conversation, once it has: the wait ends with the caller's own abort. */
|
|
69
|
+
const reached = (id, context) => {
|
|
70
|
+
const found = attached.get(Number(id));
|
|
71
|
+
if (found)
|
|
72
|
+
return Promise.resolve(found);
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const waiters = waiting.get(Number(id)) ?? new Set();
|
|
75
|
+
waiting.set(Number(id), waiters);
|
|
76
|
+
const signal = context.abortSignal;
|
|
77
|
+
const take = (value) => { signal?.removeEventListener("abort", stopped); resolve(value); };
|
|
78
|
+
const stopped = () => { waiters.delete(take); reject(signal?.reason ?? new Error("the node was stopped before its runner reached it again")); };
|
|
79
|
+
waiters.add(take);
|
|
80
|
+
if (signal?.aborted)
|
|
81
|
+
stopped();
|
|
82
|
+
else
|
|
83
|
+
signal?.addEventListener("abort", stopped, { once: true });
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
const attach = (id, value) => {
|
|
87
|
+
attached.set(Number(id), value);
|
|
88
|
+
for (const take of waiting.get(Number(id)) ?? [])
|
|
89
|
+
take(value);
|
|
90
|
+
waiting.delete(Number(id));
|
|
91
|
+
return () => { if (attached.get(Number(id)) === value)
|
|
92
|
+
attached.delete(Number(id)); };
|
|
93
|
+
};
|
|
94
|
+
/** The contract of a `submit` call: a call cut after its record was committed is answered from the record and
|
|
95
|
+
* waits for nobody; any other waits for the runner. Only a conversation of an older build's node may be reached
|
|
96
|
+
* with no configuration document yet. */
|
|
97
|
+
const contractOf = (legacy) => async (api, context) => {
|
|
98
|
+
if ((await api.snapshot(RecordDoc, api.conversationId, context))?.record != null)
|
|
99
|
+
return { schema: {} };
|
|
100
|
+
if (!legacy && !(await api.snapshot(NodeDoc, api.conversationId, context))?.schema)
|
|
101
|
+
throw new Error("submit was called in a conversation that is no workflow node");
|
|
102
|
+
return (await reached(api.conversationId, context)).contract;
|
|
103
|
+
};
|
|
104
|
+
/** Each request presents `submit` as the node's own record's tool. */
|
|
105
|
+
const present = async (request, api, context) => {
|
|
106
|
+
const node = await api.snapshot(NodeDoc, api.conversationId, context);
|
|
107
|
+
if (!node?.schema)
|
|
108
|
+
return undefined;
|
|
109
|
+
await reached(api.conversationId, context);
|
|
110
|
+
const shown = submitDefinition({ schema: node.schema, label: node.label, ...(node.fileKey ? { fileKey: node.fileKey } : {}) });
|
|
111
|
+
return { messages: request.messages.map((message) => message.role === "system" && message.toolsAdded?.some(isSubmit)
|
|
112
|
+
? { ...message, toolsAdded: message.toolsAdded.map((offered) => isSubmit(offered) ? { ...offered, ...shown } : offered) } : message) };
|
|
113
|
+
};
|
|
114
|
+
const nudge = recordNudge({
|
|
115
|
+
maxAttempts,
|
|
116
|
+
label: async (api, context) => (await api.snapshot(NodeDoc, api.conversationId, context))?.label || undefined,
|
|
117
|
+
spend: async (id, context) => (await reached(id, context)).commit((tx) => spendNudge(tx, id), context),
|
|
118
|
+
});
|
|
119
|
+
const tool = recordTool({ ...host.submit, contract: contractOf(false) });
|
|
120
|
+
const extension = defineExtension({
|
|
121
|
+
name: options.name ?? "agentrun-nodes",
|
|
122
|
+
tools: [tool],
|
|
123
|
+
sections: [section("task", async (input, context) => (await input.read.snapshot(NodeDoc, input.conversationId, context))?.system || undefined, { tag: false })],
|
|
124
|
+
hooks: [hook(GenerationTask, { beforeRequest: present }), nudge],
|
|
125
|
+
});
|
|
126
|
+
/** The host's agent with the runner's extension first and `submit` last, unless the host placed them. */
|
|
127
|
+
const withNodes = (agent) => ({
|
|
128
|
+
...agent,
|
|
129
|
+
extensions: listed(agent.extensions)
|
|
130
|
+
? (agent.extensions.some((selected) => selected.name === extension.name) ? agent.extensions : [extension, ...agent.extensions])
|
|
131
|
+
: { ...agent.extensions, add: [extension, ...(agent.extensions?.add ?? [])] },
|
|
132
|
+
...(listed(agent.tools) && !agent.tools.some(isSubmit) ? { tools: [...agent.tools, tool] } : {}),
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
extension,
|
|
136
|
+
legacy: (entry) => defineExtension({
|
|
137
|
+
name: entry.extension,
|
|
138
|
+
tools: [recordTool({ ...host.submit, contract: contractOf(true) })],
|
|
139
|
+
sections: entry.system === null ? [] : [section("task", () => entry.system ?? undefined, { tag: false })],
|
|
140
|
+
hooks: [hook(GenerationTask, { beforeRequest: async (request, api, context) => { await reached(api.conversationId, context); return present(request, api, context); } }), nudge],
|
|
141
|
+
}),
|
|
142
|
+
on: (scope, base) => ({
|
|
143
|
+
closeStepSession: (sessionId) => closeNodeAttempt(scope, sessionId, base),
|
|
144
|
+
runNode: async (params) => {
|
|
145
|
+
const { review, signal, step, ...asked } = params;
|
|
146
|
+
// An attempt the driver admitted is found again by its session; any other call is a conversation of its own.
|
|
147
|
+
const node = { ...asked, sessionId: step?.sessionId ?? `unjournaled:${randomUUID()}`, attempt: step?.attempt ?? 0, earlierSessionIds: step?.earlierSessionIds ?? [] };
|
|
148
|
+
const fail = (message, final, unanswered) => new NodeFailure(message, node.label, final, node.executionPath, unanswered);
|
|
149
|
+
// Only a schema no submit call can satisfy is refused here: any other is the node's contract as its author wrote it.
|
|
150
|
+
const lint = lintRecordRoot(node.schema);
|
|
151
|
+
if (lint.length)
|
|
152
|
+
throw fail(`${node.kind} node "${node.label}" cannot deliver a record of its schema: ${lint.join("; ")}`, true);
|
|
153
|
+
// The host's own answers are awaited under the stop too: one that lands meanwhile opens no conversation.
|
|
154
|
+
const { agent, record } = await underStop(signal, async () => ({ agent: await host.agent(node), record: await host.record?.(node) ?? {} }));
|
|
155
|
+
const label = record.label ?? `the ${node.label} record`;
|
|
156
|
+
const offered = (listed(agent.tools) ? agent.tools : listed(agent.extensions) ? agent.extensions.flatMap((selected) => selected.tools ?? []) : [])
|
|
157
|
+
.map((offer) => offer.name).filter((name) => name !== "submit");
|
|
158
|
+
const system = [...node.system, host.stance?.(node, { tools: offered })].filter(Boolean).join("\n\n");
|
|
159
|
+
const reviewers = [
|
|
160
|
+
...(host.verify ? [(candidate, at) => host.verify(candidate, node, at)] : []),
|
|
161
|
+
...(review ? [async (candidate) => {
|
|
162
|
+
const verdict = await review(candidate);
|
|
163
|
+
return verdict.accepted ? [] : [{ id: "verify", kind: "verify", verdict: "fails", reasons: [`/: violates "the verify clause of ${node.label}" - ${verdict.message}`] }];
|
|
164
|
+
}] : []),
|
|
165
|
+
];
|
|
166
|
+
// The run's stop ends the wait at once, whatever the node is doing; the attempt goes on only under a pause.
|
|
167
|
+
let onStop;
|
|
168
|
+
const stopped = new Promise((_, reject) => { onStop = () => reject(signal.reason); });
|
|
169
|
+
stopped.catch(() => undefined);
|
|
170
|
+
let opened;
|
|
171
|
+
const attempt = runNodeAttempt(scope, {
|
|
172
|
+
session: node.sessionId,
|
|
173
|
+
digest: host.digest?.(node) ?? canonicalSha256({ step: node.label, path: node.executionPath ?? null, kind: node.kind, item: node.item?.index ?? null, attempt: node.attempt }),
|
|
174
|
+
agent: withNodes(agent),
|
|
175
|
+
user: `${node.user}\n${submitFooter({ label, schema: node.schema, reviewed: reviewers.length > 0, ...(record.file ? { fileKey: record.file.key } : {}), ...(record.instructions ? { instructions: record.instructions } : {}) })}`,
|
|
176
|
+
init: async (tx, id) => {
|
|
177
|
+
Object.assign(await tx.doc(NodeDoc, id), { system, label, schema: node.schema, fileKey: record.file?.key ?? null });
|
|
178
|
+
await host.init?.(node, tx, id);
|
|
179
|
+
},
|
|
180
|
+
adopt: async (tx, id, stored) => {
|
|
181
|
+
const held = await tx.doc(NodeDoc, id);
|
|
182
|
+
if (held.schema !== null)
|
|
183
|
+
return;
|
|
184
|
+
Object.assign(held, { system: typeof stored.system === "string" ? stored.system : system, label, schema: node.schema, fileKey: record.file?.key ?? null });
|
|
185
|
+
// The older build selected an extension of its own for this conversation, which is not installed here.
|
|
186
|
+
const own = typeof stored.extension === "string" ? [{ name: stored.extension }] : [];
|
|
187
|
+
const given = withNodes(agent);
|
|
188
|
+
await configure(tx, id, { ...given, ...(listed(given.extensions) ? {} : { extensions: { ...given.extensions, remove: [...(given.extensions?.remove ?? []), ...own] } }),
|
|
189
|
+
...(listed(agent.tools) ? {} : { tools: null }) });
|
|
190
|
+
},
|
|
191
|
+
onOpen: async ({ conversationId, resumed, startedMs }) => {
|
|
192
|
+
opened = { conversationId, detach: () => undefined };
|
|
193
|
+
// What pi resumed of this node waits for the attach below, so the host observes the node first. A host
|
|
194
|
+
// that throws here attaches nothing: that work stays waiting, for the attempt's next entry or the close.
|
|
195
|
+
await host.onNodeOpen?.({ ...node, conversationId, resumed });
|
|
196
|
+
// A stop that landed before the node's request is submitted: nothing is asked of a model.
|
|
197
|
+
if (signal?.aborted)
|
|
198
|
+
throw signal.reason;
|
|
199
|
+
opened.detach = attach(conversationId, { commit: scope.commit, contract: {
|
|
200
|
+
schema: node.schema, maxAttempts, ...(reviewers.length ? { reviewers } : {}),
|
|
201
|
+
...(record.contracts ? { contracts: record.contracts } : {}), ...(record.checks ? { checks: record.checks } : {}),
|
|
202
|
+
...(record.file ? { file: workspaceRecordFile({ ...record.file, notBefore: () => startedMs }) } : {}),
|
|
203
|
+
} });
|
|
204
|
+
},
|
|
205
|
+
}, base);
|
|
206
|
+
attempt.catch(() => undefined);
|
|
207
|
+
// Listened for from here, where the `finally` below is the only way out: no listener outlives the call.
|
|
208
|
+
if (signal?.aborted)
|
|
209
|
+
onStop();
|
|
210
|
+
else
|
|
211
|
+
signal?.addEventListener("abort", onStop, { once: true });
|
|
212
|
+
try {
|
|
213
|
+
const outcome = await Promise.race([attempt, stopped]);
|
|
214
|
+
if (outcome.delivered)
|
|
215
|
+
return outcome.delivered.record;
|
|
216
|
+
if (outcome.settled?.status === "unanswered") {
|
|
217
|
+
const { reason, detail } = outcome.settled;
|
|
218
|
+
throw fail(`${node.label}: the model did not answer (${reason})`, true, { reason, ...(detail !== undefined ? { detail } : {}) });
|
|
219
|
+
}
|
|
220
|
+
const spent = ((await scope.snapshot(RecordDoc, outcome.conversationId, base))?.attempts ?? 0) >= maxAttempts;
|
|
221
|
+
throw fail(`${node.label} did not submit (${spent ? "its delivery attempts are spent" : "it ended its run without a record"})`, spent);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (!signal?.aborted)
|
|
225
|
+
throw error;
|
|
226
|
+
// A pause keeps the node's work for the process that resumes it; any other stop ends that work, and a
|
|
227
|
+
// request submitted as the stop landed is withdrawn once it is known.
|
|
228
|
+
const end = async () => { if (opened)
|
|
229
|
+
await (await scope.conversation(opened.conversationId, base))?.abort(base).catch(() => undefined); };
|
|
230
|
+
if (runStopOf(signal.reason)?.stop !== "pause") {
|
|
231
|
+
await end();
|
|
232
|
+
void attempt.then(end, end);
|
|
233
|
+
}
|
|
234
|
+
throw signal.reason ?? error;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
signal?.removeEventListener("abort", onStop);
|
|
238
|
+
void attempt.then(() => opened?.detach(), () => opened?.detach());
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Context, JsonValue } from "@earendil-works/chord";
|
|
2
|
+
import { type AgentChange, type ConversationId, type DocumentReader, type InputSubmissionDraft, type Submission, type TaskId, type Tx } from "@earendil-works/pi-durable";
|
|
3
|
+
import type { Disagreement } from "./record.js";
|
|
4
|
+
/** The node index: an attempt's conversation by its session id, the digest of what it was bound to, when it started,
|
|
5
|
+
* and whether its owner closed it without a record. */
|
|
6
|
+
export declare const NodeIndex: import("@earendil-works/pi-durable").SessionDocFamilyToken<{
|
|
7
|
+
conversation: number | null;
|
|
8
|
+
digest: string | null;
|
|
9
|
+
startedMs: number | null;
|
|
10
|
+
closed: boolean;
|
|
11
|
+
}, null>;
|
|
12
|
+
/** Where a node's conversation lives and who writes there: a workflow task's runtime, a tool call's api, or the
|
|
13
|
+
* Harness. `owner` is the task that owns the conversation (aborting it aborts the node); absent, nobody does. */
|
|
14
|
+
export type NodeScope = {
|
|
15
|
+
commit(write: (tx: Tx) => Promise<void>, context: Context): Promise<void>;
|
|
16
|
+
conversation(id: ConversationId, context: Context): Promise<{
|
|
17
|
+
submit(submission: InputSubmissionDraft, context: Context): Promise<Submission>;
|
|
18
|
+
abort(context: Context): Promise<void>;
|
|
19
|
+
} | undefined>;
|
|
20
|
+
snapshot: DocumentReader["snapshot"];
|
|
21
|
+
owner?: TaskId;
|
|
22
|
+
};
|
|
23
|
+
/** A task's runtime as a node scope: the task owns the conversations its nodes run in. */
|
|
24
|
+
export declare function taskScope(runtime: {
|
|
25
|
+
taskId: TaskId;
|
|
26
|
+
snapshot: DocumentReader["snapshot"];
|
|
27
|
+
conversation: NodeScope["conversation"];
|
|
28
|
+
commit(change: (tx: Tx) => Promise<undefined>, context: Context): Promise<void>;
|
|
29
|
+
}): NodeScope;
|
|
30
|
+
/** The Harness as a node scope: the host drives the node, and no task owns its conversation. */
|
|
31
|
+
export declare function hostScope(harness: {
|
|
32
|
+
snapshot: DocumentReader["snapshot"];
|
|
33
|
+
conversation: NodeScope["conversation"];
|
|
34
|
+
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
|
|
35
|
+
}): NodeScope;
|
|
36
|
+
export type NodeAttempt = {
|
|
37
|
+
/** The attempt's identity: the key of its index entry, and of its request (`node:<session>`). */
|
|
38
|
+
session: string;
|
|
39
|
+
/** A digest of what the attempt is bound to. An attempt found under another digest is refused, never continued. */
|
|
40
|
+
digest: string;
|
|
41
|
+
/** The agent the attempt's conversation is created with: model, thinking, extensions, tools, instructions. */
|
|
42
|
+
agent: AgentChange;
|
|
43
|
+
/** The node's input: the conversation's one user turn. */
|
|
44
|
+
user: string;
|
|
45
|
+
/** Written in the commit that creates the conversation: the documents the conversation's tools and sections read. */
|
|
46
|
+
init?(tx: Tx, conversationId: ConversationId): void | Promise<void>;
|
|
47
|
+
/** Run in the commit that finds the conversation an earlier process created, with its index entry as stored (an
|
|
48
|
+
* older build's entry holds more fields): what a conversation that older build opened needs to run here. */
|
|
49
|
+
adopt?(tx: Tx, conversationId: ConversationId, stored: Readonly<Record<string, unknown>>): void | Promise<void>;
|
|
50
|
+
/** Called, and awaited, once the conversation exists and before its request: the host attaches what observes it.
|
|
51
|
+
* `resumed` says an earlier process created it, `startedMs` when. */
|
|
52
|
+
onOpen?(at: {
|
|
53
|
+
conversationId: ConversationId;
|
|
54
|
+
resumed: boolean;
|
|
55
|
+
startedMs: number;
|
|
56
|
+
}): void | Promise<void>;
|
|
57
|
+
};
|
|
58
|
+
/** How an attempt's submission ended: answered, or not, with pi's reason (`aborted`, `failed`, ...) and detail. */
|
|
59
|
+
export type NodeSettlement = {
|
|
60
|
+
status: "done";
|
|
61
|
+
} | {
|
|
62
|
+
status: "unanswered";
|
|
63
|
+
reason: string;
|
|
64
|
+
detail?: JsonValue;
|
|
65
|
+
};
|
|
66
|
+
/** How an attempt came out. `delivered` is the record its conversation committed, if it did; `settled` is how the
|
|
67
|
+
* submission ended, absent when the record was already there and no request was made. */
|
|
68
|
+
export type NodeOutcome = {
|
|
69
|
+
conversationId: ConversationId;
|
|
70
|
+
resumed: boolean;
|
|
71
|
+
settled?: NodeSettlement;
|
|
72
|
+
delivered?: {
|
|
73
|
+
record: JsonValue;
|
|
74
|
+
disagreements: Disagreement[];
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
/** An attempt found bound to something else than it is asked to run under. */
|
|
78
|
+
export declare class NodeBindingMismatch extends Error {
|
|
79
|
+
readonly session: string;
|
|
80
|
+
readonly stored: string | null;
|
|
81
|
+
readonly asked: string;
|
|
82
|
+
readonly code = "NODE_BINDING_MISMATCH";
|
|
83
|
+
constructor(session: string, stored: string | null, asked: string);
|
|
84
|
+
}
|
|
85
|
+
/** Run one attempt. */
|
|
86
|
+
export declare function runNodeAttempt(scope: NodeScope, attempt: NodeAttempt, context: Context): Promise<NodeOutcome>;
|
|
87
|
+
/** An attempt its owner closed without a record: the mark a reader tells it from one still running by. */
|
|
88
|
+
export declare function closeNodeAttempt(scope: Pick<NodeScope, "commit">, session: string, context: Context): Promise<void>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { configure, defineDocFamily, } from "@earendil-works/pi-durable";
|
|
2
|
+
import { deliveredRecord } from "./record-tool.js";
|
|
3
|
+
/** The node index: an attempt's conversation by its session id, the digest of what it was bound to, when it started,
|
|
4
|
+
* and whether its owner closed it without a record. */
|
|
5
|
+
export const NodeIndex = defineDocFamily({
|
|
6
|
+
kind: "agentrun.nodes", version: 1, scope: "session", family: true, initial: () => ({ conversation: null, digest: null, startedMs: null, closed: false }),
|
|
7
|
+
});
|
|
8
|
+
/** A task's runtime as a node scope: the task owns the conversations its nodes run in. */
|
|
9
|
+
export function taskScope(runtime) {
|
|
10
|
+
return {
|
|
11
|
+
owner: runtime.taskId,
|
|
12
|
+
snapshot: runtime.snapshot.bind(runtime),
|
|
13
|
+
conversation: (id, context) => runtime.conversation(id, context),
|
|
14
|
+
commit: (write, context) => runtime.commit(async (tx) => { await write(tx); return undefined; }, context),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** The Harness as a node scope: the host drives the node, and no task owns its conversation. */
|
|
18
|
+
export function hostScope(harness) {
|
|
19
|
+
return {
|
|
20
|
+
snapshot: harness.snapshot.bind(harness),
|
|
21
|
+
conversation: (id, context) => harness.conversation(id, context),
|
|
22
|
+
commit: (write, context) => harness.commit(write, context),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** An attempt found bound to something else than it is asked to run under. */
|
|
26
|
+
export class NodeBindingMismatch extends Error {
|
|
27
|
+
session;
|
|
28
|
+
stored;
|
|
29
|
+
asked;
|
|
30
|
+
code = "NODE_BINDING_MISMATCH";
|
|
31
|
+
constructor(session, stored, asked) {
|
|
32
|
+
super(`node attempt ${session} is bound to ${stored ?? "nothing"}, not ${asked}: it is not continued under another binding`);
|
|
33
|
+
this.session = session;
|
|
34
|
+
this.stored = stored;
|
|
35
|
+
this.asked = asked;
|
|
36
|
+
this.name = "NodeBindingMismatch";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Run one attempt. */
|
|
40
|
+
export async function runNodeAttempt(scope, attempt, context) {
|
|
41
|
+
let found;
|
|
42
|
+
await scope.commit(async (tx) => {
|
|
43
|
+
const entry = await tx.doc(NodeIndex, attempt.session, null);
|
|
44
|
+
if (entry.conversation !== null) {
|
|
45
|
+
found = { conversationId: entry.conversation, digest: entry.digest, resumed: true, startedMs: entry.startedMs ?? 0 };
|
|
46
|
+
// An attempt under another binding is refused below, untouched.
|
|
47
|
+
if (entry.digest === attempt.digest)
|
|
48
|
+
await attempt.adopt?.(tx, found.conversationId, entry);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const conversation = await tx.createConversation({ ownership: scope.owner !== undefined ? { kind: "task", taskId: scope.owner } : { kind: "ownerless" } });
|
|
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 };
|
|
58
|
+
}, context);
|
|
59
|
+
const { conversationId, digest, resumed, startedMs } = found;
|
|
60
|
+
if (digest !== attempt.digest)
|
|
61
|
+
throw new NodeBindingMismatch(attempt.session, digest, attempt.digest);
|
|
62
|
+
await attempt.onOpen?.({ conversationId, resumed, startedMs });
|
|
63
|
+
// A record the attempt already delivered is the node's record: nothing is requested for it.
|
|
64
|
+
const already = await deliveredRecord(scope, conversationId, context);
|
|
65
|
+
if (already)
|
|
66
|
+
return { conversationId, resumed, delivered: already };
|
|
67
|
+
const handle = await scope.conversation(conversationId, context);
|
|
68
|
+
if (!handle)
|
|
69
|
+
throw new Error(`node attempt ${attempt.session} names conversation ${String(conversationId)}, which does not exist`);
|
|
70
|
+
const submission = await handle.submit({ type: "input", content: attempt.user, requestId: `node:${attempt.session}` }, context);
|
|
71
|
+
const ended = await submission.wait(context);
|
|
72
|
+
const settled = ended.status === "unanswered"
|
|
73
|
+
? { status: "unanswered", reason: ended.reason, ...(ended.detail !== undefined ? { detail: ended.detail } : {}) } : { status: "done" };
|
|
74
|
+
const delivered = await deliveredRecord(scope, conversationId, context);
|
|
75
|
+
return { conversationId, resumed, settled, ...(delivered ? { delivered } : {}) };
|
|
76
|
+
}
|
|
77
|
+
/** An attempt its owner closed without a record: the mark a reader tells it from one still running by. */
|
|
78
|
+
export async function closeNodeAttempt(scope, session, context) {
|
|
79
|
+
await scope.commit(async (tx) => { const entry = await tx.doc(NodeIndex, session, null); if (entry.conversation !== null)
|
|
80
|
+
entry.closed = true; }, context);
|
|
81
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Harness, ToolExecutionResult } from "@earendil-works/pi-durable";
|
|
2
|
+
import type { Context } from "@earendil-works/chord";
|
|
3
|
+
/** The key the loaded pi-durable reads a tool result's blocks from. 1.2 renamed `content` to `output` in the release
|
|
4
|
+
* that added `SessionFailed`, so that export tells the two apart. */
|
|
5
|
+
export declare const RESULT_KEY: "content" | "output";
|
|
6
|
+
export type TextBlock = {
|
|
7
|
+
type: "text";
|
|
8
|
+
text: string;
|
|
9
|
+
};
|
|
10
|
+
/** A tool result every supported pi-durable reads as `blocks`: the blocks under both keys, each its own copy. */
|
|
11
|
+
export declare function toolResult(blocks: readonly TextBlock[], fields?: Omit<ToolExecutionResult, "content" | "output">): ToolExecutionResult;
|
|
12
|
+
/** A tool result whose blocks the loaded pi-durable would not read: it reached the model empty. */
|
|
13
|
+
export declare class ToolResultKeyError extends Error {
|
|
14
|
+
readonly tool: string;
|
|
15
|
+
readonly code = "TOOL_RESULT_KEY";
|
|
16
|
+
constructor(tool: string);
|
|
17
|
+
}
|
|
18
|
+
/** The blocks of a result `tool` returned, as the loaded pi-durable reads them; undefined for a result with no blocks
|
|
19
|
+
* (pi then shows what the tool streamed). A result whose blocks are only under the other version's key throws
|
|
20
|
+
* `ToolResultKeyError`. */
|
|
21
|
+
export declare function resultBlocks(tool: string, result: unknown): unknown[] | undefined;
|
|
22
|
+
/** The storage error behind a Session that failed at one (`SessionFailed` on 1.2, matched by name), else the error. */
|
|
23
|
+
export declare function storageError(error: unknown): unknown;
|
|
24
|
+
/** A live task of a newer pi-durable than the loaded one: pi-durable 1.2 keeps a tool call in flight as a version 2
|
|
25
|
+
* `pi.tool` task, which an earlier version waits on without running. */
|
|
26
|
+
export declare class NewerTaskError extends Error {
|
|
27
|
+
readonly kind: string;
|
|
28
|
+
readonly version: number;
|
|
29
|
+
readonly code = "PI_TASK_NEWER";
|
|
30
|
+
constructor(kind: string, version: number);
|
|
31
|
+
}
|
|
32
|
+
/** Refuse a store a newer pi-durable left with a tool call in flight, instead of waiting on it: a host calls this after
|
|
33
|
+
* opening its Harness and before resuming it. On pi-durable 1.2 it does nothing. */
|
|
34
|
+
export declare function refuseNewerTasks(harness: Pick<Harness, "inspect">, context: Context): Promise<void>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// What differs between the pi-durable versions this package supports (1.0.4 to 1.2.x), in one place.
|
|
2
|
+
//
|
|
3
|
+
// A tool result's blocks are `content` up to 1.1 and `output` from 1.2. Each version reads its own key, ignores the other
|
|
4
|
+
// and stores only what it read, so a result that carries both is read the same by all of them. A result that carries only
|
|
5
|
+
// the other version's key reaches the model empty, with no error from pi: where this package receives a result it did not
|
|
6
|
+
// write, that is refused here. A storage error fails a 1.2 Session, and its later calls reject with `SessionFailed`
|
|
7
|
+
// whose cause is that error; 1.1 rejected with the error itself. No pi-durable name that one of the versions lacks is
|
|
8
|
+
// imported by name, since a missing named export fails the module at load.
|
|
9
|
+
import * as piDurable from "@earendil-works/pi-durable";
|
|
10
|
+
/** The key the loaded pi-durable reads a tool result's blocks from. 1.2 renamed `content` to `output` in the release
|
|
11
|
+
* that added `SessionFailed`, so that export tells the two apart. */
|
|
12
|
+
export const RESULT_KEY = "SessionFailed" in piDurable ? "output" : "content";
|
|
13
|
+
const OTHER_KEY = RESULT_KEY === "output" ? "content" : "output";
|
|
14
|
+
/** A tool result every supported pi-durable reads as `blocks`: the blocks under both keys, each its own copy. */
|
|
15
|
+
export function toolResult(blocks, fields = {}) {
|
|
16
|
+
return { ...fields, content: blocks.map((block) => ({ ...block })), output: blocks.map((block) => ({ ...block })) };
|
|
17
|
+
}
|
|
18
|
+
/** A tool result whose blocks the loaded pi-durable would not read: it reached the model empty. */
|
|
19
|
+
export class ToolResultKeyError extends Error {
|
|
20
|
+
tool;
|
|
21
|
+
code = "TOOL_RESULT_KEY";
|
|
22
|
+
constructor(tool) {
|
|
23
|
+
super(`${tool} returned its result under "${OTHER_KEY}", and the loaded pi-durable reads "${RESULT_KEY}": the model would receive an empty result. Return the blocks under "${RESULT_KEY}", or under both keys.`);
|
|
24
|
+
this.tool = tool;
|
|
25
|
+
this.name = "ToolResultKeyError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The blocks of a result `tool` returned, as the loaded pi-durable reads them; undefined for a result with no blocks
|
|
29
|
+
* (pi then shows what the tool streamed). A result whose blocks are only under the other version's key throws
|
|
30
|
+
* `ToolResultKeyError`. */
|
|
31
|
+
export function resultBlocks(tool, result) {
|
|
32
|
+
if (!result || typeof result !== "object")
|
|
33
|
+
return undefined;
|
|
34
|
+
const own = result[RESULT_KEY];
|
|
35
|
+
if (own !== undefined)
|
|
36
|
+
return Array.isArray(own) ? own : undefined;
|
|
37
|
+
if (result[OTHER_KEY] !== undefined)
|
|
38
|
+
throw new ToolResultKeyError(tool);
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
/** The storage error behind a Session that failed at one (`SessionFailed` on 1.2, matched by name), else the error. */
|
|
42
|
+
export function storageError(error) {
|
|
43
|
+
return error instanceof Error && error.name === "SessionFailed" && error.cause !== undefined ? error.cause : error;
|
|
44
|
+
}
|
|
45
|
+
/** A live task of a newer pi-durable than the loaded one: pi-durable 1.2 keeps a tool call in flight as a version 2
|
|
46
|
+
* `pi.tool` task, which an earlier version waits on without running. */
|
|
47
|
+
export class NewerTaskError extends Error {
|
|
48
|
+
kind;
|
|
49
|
+
version;
|
|
50
|
+
code = "PI_TASK_NEWER";
|
|
51
|
+
constructor(kind, version) {
|
|
52
|
+
super(`A live ${kind} task is version ${version}, which pi-durable 1.2 or later runs and the loaded pi-durable (before 1.2) cannot: continue the run on pi-durable 1.2 or later.`);
|
|
53
|
+
this.kind = kind;
|
|
54
|
+
this.version = version;
|
|
55
|
+
this.name = "NewerTaskError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Refuse a store a newer pi-durable left with a tool call in flight, instead of waiting on it: a host calls this after
|
|
59
|
+
* opening its Harness and before resuming it. On pi-durable 1.2 it does nothing. */
|
|
60
|
+
export async function refuseNewerTasks(harness, context) {
|
|
61
|
+
if (RESULT_KEY === "output")
|
|
62
|
+
return;
|
|
63
|
+
const newer = (await harness.inspect(context)).tasks.find(({ record }) => record.kind === "pi.tool" && record.version > 1)?.record;
|
|
64
|
+
if (newer)
|
|
65
|
+
throw new NewerTaskError(newer.kind, newer.version);
|
|
66
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RecoveryEffect, RecoveryNote } from "@parcha/agentrun-dsl/recovery";
|
|
2
|
+
type Database = import("node:sqlite").DatabaseSync;
|
|
3
|
+
type Json = any;
|
|
4
|
+
/** A journal read back from the file: what `documentStore` keeps under one key. */
|
|
5
|
+
export type JournalView = {
|
|
6
|
+
binding: string;
|
|
7
|
+
inputs: Record<string, string> | null;
|
|
8
|
+
generation: number;
|
|
9
|
+
revision: number;
|
|
10
|
+
state: Json;
|
|
11
|
+
notes: RecoveryNote[];
|
|
12
|
+
effects: RecoveryEffect[];
|
|
13
|
+
};
|
|
14
|
+
/** The run's store `file`, opened read-only for `read`; null when there is no such file. Every query of one `read` runs
|
|
15
|
+
* in one read transaction, so what it returns is one commit's view, whatever a writer commits meanwhile. */
|
|
16
|
+
export declare function withDurableFile<T>(file: string, read: (db: Database) => T): T | null;
|
|
17
|
+
/** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
|
|
18
|
+
export declare function durableDoc<T = Record<string, unknown>>(file: string, kind: string, key?: string): T | undefined;
|
|
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. */
|
|
21
|
+
export declare function readJournal(file: string, key: string): JournalView | undefined;
|
|
22
|
+
/** Every committed route decision a frozen snapshot holds (each judge's answer, whole), by execution path. Empty for a
|
|
23
|
+
* state that is no snapshot. */
|
|
24
|
+
export declare function committedRoutes(state: unknown): Record<string, Json>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Read-only views of a run's durable file, synchronously and without the Harness: a document by its address, a
|
|
2
|
+
// journal as `documentStore` wrote it, and the route decisions a frozen snapshot holds. A reader that only looks at
|
|
3
|
+
// what ran (the run card, a tail that inherits what a run decided) uses them; the Harness stays the only writer. The
|
|
4
|
+
// readers take the store file itself (pi-durable's SQLite file, wherever the host keeps it), never a directory.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { apply } from "@earendil-works/chord/delta";
|
|
8
|
+
import { effectKey } from "./store.js";
|
|
9
|
+
/** The run's store `file`, opened read-only for `read`; null when there is no such file. Every query of one `read` runs
|
|
10
|
+
* in one read transaction, so what it returns is one commit's view, whatever a writer commits meanwhile. */
|
|
11
|
+
export function withDurableFile(file, read) {
|
|
12
|
+
if (!fs.existsSync(file))
|
|
13
|
+
return null;
|
|
14
|
+
const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
|
|
15
|
+
const db = new DatabaseSync(file, { readOnly: true, timeout: 5000 });
|
|
16
|
+
try {
|
|
17
|
+
db.exec("BEGIN");
|
|
18
|
+
return read(db);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
db.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** A document's value: its latest base with every later delta applied, as the storage materializes it. */
|
|
25
|
+
function revisionsOf(db, id) {
|
|
26
|
+
const base = db.prepare("SELECT seq, content FROM document_revisions WHERE document_id = ? AND kind = 'base' ORDER BY seq DESC LIMIT 1").get(id);
|
|
27
|
+
if (!base)
|
|
28
|
+
return undefined;
|
|
29
|
+
let value = JSON.parse(base.content);
|
|
30
|
+
for (const delta of db.prepare("SELECT content FROM document_revisions WHERE document_id = ? AND seq > ? ORDER BY seq").all(id, base.seq)) {
|
|
31
|
+
value = apply(value, JSON.parse(delta.content));
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/** A Session document's current value. `key` names a family member; a singleton has none. */
|
|
36
|
+
function sessionDoc(db, kind, key) {
|
|
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
|
+
.get(JSON.stringify(kind), key === undefined ? 0 : 1, JSON.stringify(key ?? ""));
|
|
39
|
+
return row?.id === undefined ? undefined : revisionsOf(db, row.id);
|
|
40
|
+
}
|
|
41
|
+
/** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
|
|
42
|
+
export function durableDoc(file, kind, key) {
|
|
43
|
+
return (withDurableFile(file, (db) => sessionDoc(db, kind, key)) ?? undefined);
|
|
44
|
+
}
|
|
45
|
+
/** 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. */
|
|
47
|
+
export function readJournal(file, key) {
|
|
48
|
+
return withDurableFile(file, (db) => {
|
|
49
|
+
const doc = sessionDoc(db, "agentrun.driver", key);
|
|
50
|
+
if (!doc || doc.binding === null)
|
|
51
|
+
return undefined;
|
|
52
|
+
const effects = doc.effects.map((id) => {
|
|
53
|
+
const e = sessionDoc(db, "agentrun.effects", effectKey(key, id));
|
|
54
|
+
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
|
+
});
|
|
56
|
+
return { binding: doc.binding, inputs: doc.inputs, generation: doc.generation, revision: doc.revision, state: doc.state, notes: doc.notes, effects };
|
|
57
|
+
}) ?? undefined;
|
|
58
|
+
}
|
|
59
|
+
/** Every committed route decision a frozen snapshot holds (each judge's answer, whole), by execution path. Empty for a
|
|
60
|
+
* state that is no snapshot. */
|
|
61
|
+
export function committedRoutes(state) {
|
|
62
|
+
const routes = state?.pin?.routes;
|
|
63
|
+
return routes && typeof routes === "object" && !Array.isArray(routes) ? { ...routes } : {};
|
|
64
|
+
}
|