@parcha/agentrun-pi 0.1.0-beta.12 → 0.1.0-beta.13

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.
Files changed (47) hide show
  1. package/README.md +20 -8
  2. package/dist/cli.js +10 -1
  3. package/dist/demo-cli.d.ts +2 -0
  4. package/dist/demo-cli.js +25 -0
  5. package/dist/durable/decision-recovery.d.ts +23 -0
  6. package/dist/durable/decision-recovery.js +57 -0
  7. package/dist/durable/decisions.d.ts +113 -0
  8. package/dist/durable/decisions.js +71 -0
  9. package/dist/durable/index.d.ts +19 -0
  10. package/dist/durable/index.js +13 -0
  11. package/dist/durable/node-runner.d.ts +100 -0
  12. package/dist/durable/node-runner.js +243 -0
  13. package/dist/durable/node.d.ts +88 -0
  14. package/dist/durable/node.js +81 -0
  15. package/dist/durable/pi-versions.d.ts +34 -0
  16. package/dist/durable/pi-versions.js +66 -0
  17. package/dist/durable/readers.d.ts +25 -0
  18. package/dist/durable/readers.js +64 -0
  19. package/dist/durable/receipts.d.ts +52 -0
  20. package/dist/durable/receipts.js +91 -0
  21. package/dist/durable/record-file.d.ts +10 -0
  22. package/dist/durable/record-file.js +56 -0
  23. package/dist/durable/record-tool.d.ts +76 -0
  24. package/dist/durable/record-tool.js +123 -0
  25. package/dist/durable/record.d.ts +157 -0
  26. package/dist/durable/record.js +402 -0
  27. package/dist/durable/recording.d.ts +90 -0
  28. package/dist/durable/recording.js +292 -0
  29. package/dist/durable/store.d.ts +23 -0
  30. package/dist/durable/store.js +192 -0
  31. package/dist/durable/task.d.ts +129 -0
  32. package/dist/durable/task.js +395 -0
  33. package/dist/extension-service.js +14 -1
  34. package/dist/extension.d.ts +2 -0
  35. package/dist/extension.js +98 -13
  36. package/dist/host-session.js +3 -1
  37. package/dist/run-error.d.ts +6 -0
  38. package/dist/run-error.js +13 -0
  39. package/dist/runner.d.ts +1 -6
  40. package/dist/runner.js +2 -12
  41. package/dist/sop.d.ts +12 -0
  42. package/dist/sop.js +29 -0
  43. package/dist/tool-context.d.ts +21 -0
  44. package/dist/tool-context.js +13 -0
  45. package/dist/workflow-session.d.ts +4 -0
  46. package/dist/workflow-session.js +3 -2
  47. package/package.json +43 -8
@@ -0,0 +1,395 @@
1
+ // A workflow run as one pi-durable task: `agentrun.workflow`, version 1, one phase `run`. The phase opens the recovery
2
+ // driver on the document store, runs the interpreter to its end inside one invocation, and makes one terminal commit;
3
+ // every journal commit in between leaves the task's own state unchanged. The journal is what resumes: a process that
4
+ // dies leaves the task pending, and the next process's `resume()` runs the phase again, which answers what committed
5
+ // from the journal. Three rules from the task spike hold here:
6
+ // - a typed failure is a committed outcome (`failed`, with the code in `error.detail`), never a throw, so pi records
7
+ // it whole instead of faulting the task with a message;
8
+ // - a pause is taken at a seam: the driver stops admitting, an effect in flight completes and commits, the journal
9
+ // commits `paused`, and the task stays pending until the host closes the Harness; reopening continues it;
10
+ // - a cancel is pi's `abortTask`, which returns once the task is marked, before it is terminal: pi ends the run
11
+ // invocation and the conversations the task owns first, then the abort handler commits `cancelled` in the journal
12
+ // and an `aborted` outcome. A cancelled run never resumes.
13
+ // A host that continues an escalated run (its ladder) does so inside the same invocation, through `escalated`, so the
14
+ // continuation it opens is a conversation the task owns: the run's stops reach it the way they reach the run's steps.
15
+ import { defineDocFamily, defineExtension, defineTask } from "@earendil-works/pi-durable";
16
+ import { runWorkflow } from "@parcha/agentrun-dsl";
17
+ import { bindingDigests, bindingMismatch, canonicalHash, isRecoveryError, openRecovery, recoveryBound, RecoveryError, withRecovery } from "@parcha/agentrun-dsl/recovery";
18
+ import { storageError } from "./pi-versions.js";
19
+ import { documentStore } from "./store.js";
20
+ const RunDoc = defineDocFamily({ kind: "agentrun.task", version: 1, scope: "session", family: true, initial: () => ({ task: null, stop: null }) });
21
+ /** Records a stop on a run's entry. A recorded cancel stands, so the first cancel names the run's source; a recorded
22
+ * pause is replaced by the stop that follows it. */
23
+ const recordStop = (run, stop) => { if (run.stop?.action !== "cancel")
24
+ run.stop = stop; };
25
+ /** The source of the cancel a run's entry records. */
26
+ const cancelSource = (run) => (run?.stop?.action === "cancel" ? run.stop.source ?? undefined : undefined);
27
+ const cancelled = (source) => ({ status: "aborted", reason: source ? `cancelled by ${source}` : "cancelled" });
28
+ const json = (value) => (value === undefined ? null : JSON.parse(JSON.stringify(value)));
29
+ /** Why a request is not the run a task holds, or undefined when it is: the same binding (the workflow as authored and
30
+ * what the host binds beside it, as the driver binds its journal) and the same input, each by its canonical digest, so
31
+ * key order does not matter. The refusal is the driver's binding mismatch and names what moved: the workflow's parts,
32
+ * what the host binds, or the input's fields (`input.<field>`). Each part is compared over its own digests, so a bound
33
+ * key named like the input (`input`) hides neither; when both moved, the refusal holds both parts' messages and names. */
34
+ const requestMoved = (held, asked) => {
35
+ const was = recoveryBound(held.workflow, held.bind), is = recoveryBound(asked.workflow, asked.bind);
36
+ const parts = [
37
+ was.binding !== is.binding && bindingMismatch(JSON.stringify(was.inputs), is.inputs),
38
+ canonicalHash(held.input) !== canonicalHash(asked.input) && bindingMismatch(JSON.stringify(bindingDigests({ input: held.input })), bindingDigests({ input: asked.input })),
39
+ ].filter((part) => Boolean(part));
40
+ if (parts.length < 2)
41
+ return parts[0];
42
+ return Object.assign(new RecoveryError(parts.map((part) => part.message).join("; "), "RUN_STORE_BINDING_MISMATCH"), { moved: parts.flatMap((part) => part.moved ?? []) });
43
+ };
44
+ const codeOf = (error) => {
45
+ const code = error?.code;
46
+ return typeof code === "string" && code ? code : "WORKFLOW_FAILED";
47
+ };
48
+ const messageOf = (error) => String(error?.message ?? error);
49
+ /** A failure's own fields that are plain JSON (a host's typed failure: its stage, its node, its cost), so the host can
50
+ * read back the failure it raised. The stack and the cause stay out. */
51
+ const ownFields = (error) => {
52
+ const out = {};
53
+ if (!error || typeof error !== "object")
54
+ return out;
55
+ for (const [key, value] of Object.entries(error)) {
56
+ if (key === "stack" || key === "cause" || value === undefined)
57
+ continue;
58
+ try {
59
+ out[key] = json(value);
60
+ }
61
+ catch { /* not plain JSON: left out */ }
62
+ }
63
+ return out;
64
+ };
65
+ /** The document store over one invocation's commits: a commit after the task was aborted or the Harness closed is
66
+ * refused by pi, so the driver can never write past its run's end. The task is the journal's one owner across its
67
+ * invocations: its abort handler takes the journal over from a run invocation that never closed it. */
68
+ function invocationStore(runtime, context, key) {
69
+ const host = {
70
+ commit: async (change) => {
71
+ let result;
72
+ await runtime.commit(async (tx) => { result = await change(tx); return undefined; }, context);
73
+ return result;
74
+ },
75
+ };
76
+ return documentStore(host, key, { owner: `agentrun.workflow:${runtime.taskId}`, context });
77
+ }
78
+ const uncommitted = new Map();
79
+ const uncommittedId = (key, taskId) => JSON.stringify([key, taskId]);
80
+ const uncommittedOf = (key, taskId) => {
81
+ const id = uncommittedId(key, taskId);
82
+ let entry = uncommitted.get(id);
83
+ if (!entry) {
84
+ let reject;
85
+ const promise = new Promise((_, fail) => { reject = fail; });
86
+ promise.catch(() => undefined);
87
+ const made = { promise, failed: false, waits: 0, fail: (error) => { made.failed = true; reject(error); } };
88
+ entry = made;
89
+ uncommitted.set(id, entry);
90
+ }
91
+ return entry;
92
+ };
93
+ /** Runs where this process holds them, by key: the driver a pause is handed to, and when the run ended. */
94
+ const live = new Map();
95
+ /** The task definition, the extension that carries it, and the host's three operations on a run. */
96
+ export function createWorkflowExtension(host) {
97
+ /** The run's driver over its journal, with the host's options. */
98
+ const openDriver = async (task, runtime, context, phase) => {
99
+ const { key, workflow, bind } = task.input;
100
+ const options = await host.options?.({ key, taskId: task.id, runtime, context, phase });
101
+ return openRecovery(invocationStore(runtime, context, key), workflow, { ...options, key, ...(bind ? { bind } : {}) });
102
+ };
103
+ /** The driver, then what the interpreter runs with: a host that fails to give it lets the driver go. */
104
+ const open = async (task, runtime, context) => {
105
+ const driver = await openDriver(task, runtime, context, "run");
106
+ try {
107
+ const parts = await host.run({ key: task.input.key, taskId: task.id, runtime, context, driver });
108
+ const deps = "deps" in parts
109
+ ? { ...parts.deps, signal: AbortSignal.any([driver.signal, runtime.signal, ...(parts.deps.signal ? [parts.deps.signal] : [])]) }
110
+ : withRecovery(driver, { ...parts.adapters, signal: runtime.signal }, { durableNodes: parts.durableNodes });
111
+ return { deps, driver, interpret: ("deps" in parts && parts.interpret) || runWorkflow };
112
+ }
113
+ catch (error) {
114
+ await driver.close().catch(() => undefined);
115
+ throw error;
116
+ }
117
+ };
118
+ const failed = (error) => ({ status: "failed", error: { message: messageOf(error), detail: { ...ownFields(error), code: codeOf(error) } } });
119
+ const Workflow = defineTask({
120
+ name: "agentrun.workflow", version: 1, initial: () => ({ phase: "run" }),
121
+ phases: {
122
+ run: async (task, runtime, context) => {
123
+ const { key, workflow, input } = task.input;
124
+ /** The run's one terminal commit. One the Session refuses (not an abort's) hands this process's waiters the run's
125
+ * own failure, or the refusal. */
126
+ const commitEnd = async (outcome, cause) => {
127
+ try {
128
+ await runtime.commit(() => ({ status: "terminal", outcome }), context);
129
+ }
130
+ catch (error) {
131
+ if (!runtime.signal.aborted)
132
+ uncommittedOf(key, task.id).fail(cause ?? error);
133
+ throw error;
134
+ }
135
+ };
136
+ let opened;
137
+ try {
138
+ opened = await open(task, runtime, context);
139
+ }
140
+ catch (error) {
141
+ if (runtime.signal.aborted)
142
+ throw error;
143
+ await commitEnd(failed(error), error);
144
+ return;
145
+ }
146
+ const { deps, driver, interpret } = opened;
147
+ // A stop the run's entry records is the driver's before anything runs. The entry lets go of a pause once the
148
+ // journal holds it, so the next start runs again. A failure here lets the driver go and is the run's failure,
149
+ // as a failure to open is.
150
+ let early;
151
+ try {
152
+ early = (await runtime.snapshot(RunDoc, key, context))?.stop ?? null;
153
+ if (early?.action === "pause") {
154
+ driver.stop({ action: "pause", ...(early.source ? { source: early.source } : {}) });
155
+ await driver.flush().catch(() => undefined);
156
+ await runtime.commit(async (tx) => { const run = await tx.doc(RunDoc, key, null); if (run.stop?.action === "pause")
157
+ run.stop = null; return undefined; }, context);
158
+ }
159
+ }
160
+ catch (error) {
161
+ await driver.close().catch(() => undefined);
162
+ if (runtime.signal.aborted)
163
+ throw error;
164
+ await commitEnd(failed(error), error);
165
+ return;
166
+ }
167
+ let end;
168
+ live.set(key, { driver, ended: new Promise((resolve) => { end = resolve; }) });
169
+ let outcome;
170
+ let cause;
171
+ const ladder = host.escalated;
172
+ /** The run continues on the host's ladder; the row the host committed rides on the outcome. */
173
+ const handOff = async (ending) => {
174
+ const continuation = await ladder(ending, { key, taskId: task.id, runtime, context, driver, signal: AbortSignal.any([runtime.signal, driver.signal]) });
175
+ await driver.flush();
176
+ const row = driver.escalation();
177
+ if (!row)
178
+ throw Object.assign(new Error("The host continued the run without committing its escalation"), { code: "ESCALATION_NOT_COMMITTED" });
179
+ return { status: "completed", result: { status: "escalated", escalation: json(row), state: json(ending.state), continuation: json(continuation) } };
180
+ };
181
+ /** A pause the driver took: the task stays pending until the host closes the Harness. */
182
+ const paused = () => driver.signal.aborted && driver.signal.reason?.code === "FROZEN_PAUSED";
183
+ try {
184
+ const prior = ladder ? driver.escalation() : undefined;
185
+ if (early?.action === "cancel") {
186
+ // A cancel recorded before pi marked the task (the process died between the two) is taken before any step
187
+ // and before the host's ladder on a run reopened after its escalation: this invocation ends the run as the
188
+ // abort handler would have.
189
+ driver.stop({ action: "cancel", ...(early.source ? { source: early.source } : {}) });
190
+ await driver.flush();
191
+ outcome = cancelled(early.source ?? undefined);
192
+ }
193
+ else if (prior) {
194
+ driver.checkStop();
195
+ outcome = await handOff({ row: prior, state: prior.state });
196
+ }
197
+ else if (driver.preStepRefusal) {
198
+ // A refusal found before any step: nothing runs. With a ladder, the host continues the run from it.
199
+ if (!ladder)
200
+ throw driver.preStepRefusal;
201
+ outcome = await handOff({ refusal: driver.preStepRefusal, state: { ...input } });
202
+ }
203
+ else {
204
+ let run;
205
+ try {
206
+ run = await interpret(workflow, input, deps);
207
+ }
208
+ catch (error) {
209
+ // A refusal, a stop or an abort never ladders.
210
+ if (!ladder || runtime.signal.aborted || driver.signal.aborted || isRecoveryError(error))
211
+ throw error;
212
+ outcome = await handOff({ error, state: driver.lastState() });
213
+ }
214
+ if (run)
215
+ outcome = run.status === "complete"
216
+ ? { status: "completed", result: { status: "complete", output: json(run.output), state: json(run.state) } }
217
+ : ladder ? await handOff({ escalation: run.escalation, state: run.state })
218
+ : { status: "completed", result: { status: "escalated", escalation: json(run.escalation), state: json(run.state) } };
219
+ }
220
+ }
221
+ catch (error) {
222
+ // An abort or a close: this invocation may not decide the outcome (its commits are refused), so it only ends.
223
+ if (runtime.signal.aborted)
224
+ throw error;
225
+ if (!paused()) {
226
+ outcome = failed(error);
227
+ cause = error;
228
+ }
229
+ }
230
+ finally {
231
+ // A paused run stays live here until its invocation ends, so a pause handed over meanwhile is answered.
232
+ if (!paused())
233
+ live.delete(key);
234
+ await driver.close().catch(() => undefined);
235
+ end();
236
+ }
237
+ if (paused() || !outcome) {
238
+ // The pause is in the journal once the driver's commits have landed; the task stays pending, and its
239
+ // invocation ends when the host closes the Harness.
240
+ await new Promise((resolve) => { if (runtime.signal.aborted)
241
+ resolve();
242
+ else
243
+ runtime.signal.addEventListener("abort", () => resolve(), { once: true }); });
244
+ if (live.get(key)?.driver === driver)
245
+ live.delete(key);
246
+ throw runtime.signal.reason;
247
+ }
248
+ await commitEnd(outcome, cause);
249
+ },
250
+ },
251
+ abort: async (task, runtime, context) => {
252
+ const { key } = task.input;
253
+ const source = cancelSource(await runtime.snapshot(RunDoc, key, context).catch(() => undefined));
254
+ // The journal records the cancel, so no later open of it resumes. A journal the driver refuses (a binding that
255
+ // moved, a checkpoint it cannot read) is left as it is; the task still ends aborted.
256
+ try {
257
+ const driver = await openDriver(task, runtime, context, "abort");
258
+ driver.stop({ action: "cancel", ...(source ? { source } : {}) });
259
+ await driver.close();
260
+ }
261
+ catch { /* the outcome below is committed whatever the journal said */ }
262
+ await runtime.commit(() => ({ status: "terminal", outcome: cancelled(source) }), context);
263
+ },
264
+ });
265
+ const outcomeOf = async (harness, settled, key, context) => {
266
+ if (settled.state.status !== "terminal")
267
+ return undefined;
268
+ const outcome = settled.state.outcome;
269
+ if (outcome.status === "completed")
270
+ return outcome.result;
271
+ if (outcome.status === "aborted") {
272
+ const source = cancelSource(await harness.snapshot(RunDoc, key, context));
273
+ return { status: "stopped", stop: "cancelled", ...(source ? { source } : {}) };
274
+ }
275
+ const error = outcome.error;
276
+ return { status: "refused", code: error?.detail?.code ?? String(outcome.status).toUpperCase(), message: error?.message ?? String(outcome.reason ?? outcome.status), ...(error?.detail ? { detail: error.detail } : {}) };
277
+ };
278
+ const handle = (harness, key, taskId, started) => ({
279
+ key, taskId, started,
280
+ wait: async (context) => {
281
+ const failure = uncommittedOf(key, taskId);
282
+ failure.waits += 1;
283
+ try {
284
+ const settled = await Promise.race([harness.waitForTask(taskId, context), failure.promise]);
285
+ return (await outcomeOf(harness, settled, key, context));
286
+ }
287
+ catch (error) {
288
+ // A Session that failed at a storage error rejects its waits with `SessionFailed` (pi-durable 1.2): the
289
+ // waiters get the storage's error, as 1.1 gave it.
290
+ throw storageError(error);
291
+ }
292
+ finally {
293
+ // The last wait to end releases the entry, whether the task ended or the wait did; a failure keeps it.
294
+ failure.waits -= 1;
295
+ const id = uncommittedId(key, taskId);
296
+ if (failure.waits === 0 && !failure.failed && uncommitted.get(id) === failure)
297
+ uncommitted.delete(id);
298
+ }
299
+ },
300
+ });
301
+ return {
302
+ extension: defineExtension({ name: "agentrun.workflow", tasks: [Workflow] }),
303
+ /** Start the run `request.key` names, or reach the task that already runs it. The run's entry and its task are
304
+ * created in one commit, so two starts of one key make one task. A start that reaches a task asks for the run it
305
+ * holds: its workflow, what the host binds and its input are the task's, by canonical digest, so key order does not
306
+ * matter. One that asks for another run is refused with the driver's binding mismatch (`RUN_STORE_BINDING_MISMATCH`,
307
+ * its `moved` naming the workflow's parts, what the host binds, or the input's fields as `input.<field>`) and
308
+ * commits nothing: no task, no stop, no cancel. Another run takes another key. A live task is attached to; a task
309
+ * that ended complete, escalated or cancelled is returned as it is; one that ended refused, failed or faulted is
310
+ * replaced by a new task over the same journal, which refuses again until the host reconciles it. A journal an
311
+ * older build started, with no task, gets one here, and its driver checks the binding as it opens. A stop the host
312
+ * gave before the run started (`options.stop`) is recorded in the commit that creates or reaches the task, and is
313
+ * taken before anything runs. A cancel aborts the task before the scheduler runs it, so its abort handler commits
314
+ * it; one a process recorded and died before pi marked the task is taken by the run's next invocation, before any
315
+ * step and before the host's ladder. A pause is handed to the driver by the run's phase as it opens, before any
316
+ * step. So a pause given with a start that attaches to a run already running in this process is not taken now: it
317
+ * is recorded, and taken when the run's next invocation opens. `stop` pauses a run that is running here. */
318
+ async start(harness, request, context, options = {}) {
319
+ const conversationId = options.conversationId ?? (await harness.root(context)).id;
320
+ const stop = options.stop ? { action: options.stop.action, source: options.stop.source ?? null } : null;
321
+ const asked = json(request);
322
+ const [taskId, started] = await harness.commit(async (tx) => {
323
+ const run = await tx.doc(RunDoc, request.key, null);
324
+ if (run.task !== null) {
325
+ const prior = await tx.task(run.task);
326
+ // A request for another run than the task holds is refused, and the throw discards this commit.
327
+ const moved = prior && requestMoved(prior.input, asked);
328
+ if (moved)
329
+ throw moved;
330
+ if (prior && prior.state.status !== "terminal") {
331
+ if (stop)
332
+ recordStop(run, stop);
333
+ return [run.task, "attached"];
334
+ }
335
+ const kept = prior?.state.status === "terminal" && (prior.state.outcome.status === "completed" || prior.state.outcome.status === "aborted");
336
+ if (kept)
337
+ return [run.task, "settled"];
338
+ }
339
+ const id = await tx.createTask(Workflow, asked, { ownership: { kind: "conversation" }, conversationId: conversationId, ...(options.background ? { background: true } : {}) });
340
+ run.task = id;
341
+ run.stop = stop;
342
+ return [id, "created"];
343
+ }, context);
344
+ // A failure an earlier invocation of this task left in this process is not this one's; one still awaited is kept,
345
+ // so the waiters it has and the invocation that may yet fail share it.
346
+ const earlier = uncommittedId(request.key, taskId);
347
+ if (uncommitted.get(earlier)?.failed)
348
+ uncommitted.delete(earlier);
349
+ if (stop?.action === "cancel" && started !== "settled")
350
+ await harness.abortTask(taskId, context);
351
+ harness.resume();
352
+ return handle(harness, request.key, taskId, started);
353
+ },
354
+ /** The run's task and, once it is terminal, its outcome; undefined when no start named the key. */
355
+ async read(harness, key, context) {
356
+ const run = await harness.snapshot(RunDoc, key, context);
357
+ if (!run || run.task === null)
358
+ return undefined;
359
+ const record = await harness.getTask(run.task, context);
360
+ if (!record)
361
+ return undefined;
362
+ const outcome = await outcomeOf(harness, record, key, context);
363
+ return { taskId: record.id, status: record.state.status, ...(outcome ? { outcome } : {}) };
364
+ },
365
+ /** Pause or cancel the run. A pause resolves once the journal holds it (an effect in flight completes and commits
366
+ * first) or the run ended; the host then closes the Harness, and the task stays pending. A cancel resolves when pi
367
+ * has marked the task, before it is terminal; `wait` gives the aborted outcome. */
368
+ async stop(harness, key, action, context, source) {
369
+ if (action === "pause") {
370
+ const running = live.get(key);
371
+ if (!running)
372
+ return "not-live";
373
+ running.driver.stop({ action: "pause", ...(source ? { source } : {}) });
374
+ // The run is paused once its work has stopped, not when the journal first holds the pause: a continuation the
375
+ // host's ladder runs stops through the run's signal and may take a while, and the host lets go of the run only
376
+ // after it has. Its driver's commits have landed by then.
377
+ await running.ended;
378
+ await running.driver.flush().catch(() => undefined);
379
+ return running.driver.signal.aborted ? "paused" : "ended";
380
+ }
381
+ const taskId = await harness.commit(async (tx) => {
382
+ const run = await tx.doc(RunDoc, key, null);
383
+ if (run.task === null)
384
+ return null;
385
+ // A finished run keeps what ended it, and the first cancel of a live one names its source.
386
+ if ((await tx.task(run.task))?.state.status !== "terminal")
387
+ recordStop(run, { action: "cancel", source: source ?? null });
388
+ return run.task;
389
+ }, context);
390
+ if (taskId === null)
391
+ return "not-live";
392
+ return harness.abortTask(taskId, context);
393
+ },
394
+ };
395
+ }
@@ -1,6 +1,7 @@
1
1
  import { types as utilTypes } from 'node:util';
2
2
  import { JevError, isJevResponseReason } from '@parcha/agentrun-jev';
3
- import { PiRunError } from './runner.js';
3
+ import { RecoveryError } from '@parcha/agentrun-dsl/recovery';
4
+ import { PiRunError } from './run-error.js';
4
5
  import { ToolInputValidationError } from './tool-input-error.js';
5
6
  import { inspectWorkflow, runWorkflow, validateWorkflow, candidatePolicyErrors, EffectOutcomeUnknownError, WorkflowInvalidError, WorkflowInputInvalidError, WorkflowOutputInvalidError, WorkflowCodeError, WorkflowStateError, EffectDeadlineExceededError, } from '@parcha/agentrun-dsl';
6
7
  export class ExtensionServiceError extends Error {
@@ -93,7 +94,18 @@ async function withAdapterDiagnostic(stage, run) {
93
94
  throw error;
94
95
  }
95
96
  }
97
+ /** The code of the first RecoveryError in an error's cause chain. */
98
+ function recoveryCode(error) {
99
+ for (let current = error, depth = 0; current && depth < 16; current = current.cause, depth += 1) {
100
+ if (current instanceof RecoveryError)
101
+ return current.code;
102
+ }
103
+ return undefined;
104
+ }
96
105
  function localDiagnostic(error) {
106
+ if (recoveryCode(error) === 'FROZEN_EFFECT_UNKNOWN')
107
+ return { code: 'FROZEN_EFFECT_UNKNOWN',
108
+ message: 'A tool call of this run has no recorded outcome. It is not repeated. Find out whether it happened, then start a new run (change the input) or reconcile it in the run journal.' };
97
109
  if (error instanceof AggregateError)
98
110
  return error.errors.slice(0, 100).map(localDiagnostic).find(Boolean);
99
111
  if (error instanceof AdapterDiagnosticError)
@@ -444,6 +456,7 @@ export class WorkflowExtensionService {
444
456
  ...(options.deps.skill ? { skill: options.deps.skill } : {}),
445
457
  ...(sections.length ? { sop: sections.map(([name, text]) => `## ${name}\n${text}`).join('\n\n') } : options.deps.sop ? { sop: options.deps.sop } : {}),
446
458
  // Forward any set limit, so the interpreter rejects an invalid one rather than running without it.
459
+ ...(options.deps.recovery ? { recovery: options.deps.recovery } : {}),
447
460
  ...(options.deps.maxQuestionsPerRequest !== undefined ? { maxQuestionsPerRequest: options.deps.maxQuestionsPerRequest } : {}),
448
461
  ...(options.deps.maxStateBytesPerRequest !== undefined ? { maxStateBytesPerRequest: options.deps.maxStateBytesPerRequest } : {}),
449
462
  ...(options.deps.runNode ? { runNode: params => dispatch('agent', params.signal, () => withAdapterDiagnostic(params.label, () => options.deps.runNode({ ...params, tools: params.tools ?? [...this.allowedTools] })), true) } : {}),
@@ -34,6 +34,8 @@ export interface AgentRunExtensionOptions {
34
34
  onWorkflowEvent?: (frame: AgentRunWorkflowEvent) => unknown;
35
35
  /** Explicit null disables an execution limit; omitted fields retain finite defaults. */
36
36
  runtimeLimits?: Partial<AgentRunRuntimeLimits>;
37
+ /** The host's SOP text for workflows that name `sopSection`s. It outranks a file the operator supplies with /agentrun sop. */
38
+ sop?: string;
37
39
  }
38
40
  /** Configure the native extension without granting configuration to workflow authors. */
39
41
  export declare function createAgentRunExtension(options?: AgentRunExtensionOptions): (pi: ExtensionAPI) => void;