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

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 (114) hide show
  1. package/README.md +123 -32
  2. package/dist/cli.js +15 -4
  3. package/dist/demo-cli.d.ts +2 -0
  4. package/dist/demo-cli.js +25 -0
  5. package/dist/demo.js +1 -0
  6. package/dist/durable/decision-recovery.d.ts +23 -0
  7. package/dist/durable/decision-recovery.js +57 -0
  8. package/dist/durable/decisions.d.ts +113 -0
  9. package/dist/durable/decisions.js +71 -0
  10. package/dist/durable/index.d.ts +20 -0
  11. package/dist/durable/index.js +14 -0
  12. package/dist/durable/node-runner.d.ts +106 -0
  13. package/dist/durable/node-runner.js +253 -0
  14. package/dist/durable/node.d.ts +88 -0
  15. package/dist/durable/node.js +98 -0
  16. package/dist/durable/pi-versions.d.ts +34 -0
  17. package/dist/durable/pi-versions.js +66 -0
  18. package/dist/durable/readers.d.ts +28 -0
  19. package/dist/durable/readers.js +103 -0
  20. package/dist/durable/receipts.d.ts +54 -0
  21. package/dist/durable/receipts.js +92 -0
  22. package/dist/durable/record-file.d.ts +10 -0
  23. package/dist/durable/record-file.js +56 -0
  24. package/dist/durable/record-tool.d.ts +76 -0
  25. package/dist/durable/record-tool.js +123 -0
  26. package/dist/durable/record.d.ts +157 -0
  27. package/dist/durable/record.js +402 -0
  28. package/dist/durable/recording.d.ts +90 -0
  29. package/dist/durable/recording.js +292 -0
  30. package/dist/durable/store.d.ts +23 -0
  31. package/dist/durable/store.js +242 -0
  32. package/dist/durable/task.d.ts +133 -0
  33. package/dist/durable/task.js +411 -0
  34. package/dist/durable/workflow-tool.d.ts +32 -0
  35. package/dist/durable/workflow-tool.js +188 -0
  36. package/dist/extension-service.js +63 -22
  37. package/dist/extension.d.ts +2 -0
  38. package/dist/extension.js +463 -80
  39. package/dist/host-addendum.d.ts +4 -0
  40. package/dist/host-addendum.js +20 -0
  41. package/dist/host-session.js +5 -2
  42. package/dist/index.d.ts +1 -2
  43. package/dist/index.js +1 -2
  44. package/dist/pi-host.d.ts +7 -0
  45. package/dist/pi-host.js +22 -0
  46. package/dist/presentation.js +2 -2
  47. package/dist/run-error.d.ts +6 -0
  48. package/dist/run-error.js +13 -0
  49. package/dist/runner.d.ts +1 -6
  50. package/dist/runner.js +7 -13
  51. package/dist/signin/index.d.ts +9 -0
  52. package/dist/signin/index.js +7 -0
  53. package/dist/signin/limit.d.ts +10 -0
  54. package/dist/signin/limit.js +23 -0
  55. package/dist/signin/resolve.d.ts +47 -0
  56. package/dist/signin/resolve.js +163 -0
  57. package/dist/signin/signin.d.ts +44 -0
  58. package/dist/signin/signin.js +126 -0
  59. package/dist/signin/spec.d.ts +116 -0
  60. package/dist/signin/spec.js +181 -0
  61. package/dist/signin/store.d.ts +40 -0
  62. package/dist/signin/store.js +206 -0
  63. package/dist/skills/author/SKILL.md +138 -0
  64. package/{skills → dist/skills}/author/references/jev-decisions.md +27 -14
  65. package/dist/skills/author/references/language.md +134 -0
  66. package/dist/skills/author/references/sdk-authoring.md +11 -0
  67. package/{skills → dist/skills}/author/references/workflow-format.md +7 -15
  68. package/dist/sop.d.ts +12 -0
  69. package/dist/sop.js +29 -0
  70. package/dist/thinking.d.ts +3 -0
  71. package/dist/thinking.js +7 -0
  72. package/dist/tool-context.d.ts +21 -0
  73. package/dist/tool-context.js +13 -0
  74. package/dist/triage-demo.d.ts +62 -0
  75. package/dist/triage-demo.js +153 -0
  76. package/dist/types.d.ts +7 -0
  77. package/dist/workflow-inspector.d.ts +47 -0
  78. package/dist/workflow-inspector.js +235 -0
  79. package/dist/workflow-session.d.ts +23 -0
  80. package/dist/workflow-session.js +168 -0
  81. package/dist/workflow-store.d.ts +36 -0
  82. package/dist/workflow-store.js +255 -0
  83. package/dist/workflow-tool.d.ts +81 -0
  84. package/dist/workflow-tool.js +94 -0
  85. package/dist/workflow-view.d.ts +64 -0
  86. package/dist/workflow-view.js +273 -0
  87. package/dist/workflows.d.ts +1 -0
  88. package/dist/workflows.js +3 -0
  89. package/package.json +57 -11
  90. package/dist/admission.d.ts +0 -6
  91. package/dist/admission.js +0 -51
  92. package/dist/author.d.ts +0 -21
  93. package/dist/author.js +0 -72
  94. package/dist/benchmark-contract.d.ts +0 -88
  95. package/dist/benchmark-contract.js +0 -50
  96. package/dist/benchmark-freeze.d.ts +0 -9
  97. package/dist/benchmark-freeze.js +0 -9
  98. package/dist/benchmark.d.ts +0 -127
  99. package/dist/benchmark.js +0 -260
  100. package/dist/comparison.d.ts +0 -69
  101. package/dist/comparison.js +0 -229
  102. package/dist/skill-bundle.d.ts +0 -5
  103. package/dist/skill-bundle.js +0 -15
  104. package/skills/author/SKILL.md +0 -102
  105. package/skills/author/references/sdk-authoring.md +0 -16
  106. /package/{skills → dist/skills}/author/examples/evidence-gate.input.json +0 -0
  107. /package/{skills → dist/skills}/author/examples/evidence-gate.json +0 -0
  108. /package/{skills → dist/skills}/author/examples/read-file.input.json +0 -0
  109. /package/{skills → dist/skills}/author/examples/read-file.json +0 -0
  110. /package/{skills → dist/skills}/author/examples/read-source-decision.input.json +0 -0
  111. /package/{skills → dist/skills}/author/examples/read-source-decision.json +0 -0
  112. /package/{skills → dist/skills}/author/examples/release-note.txt +0 -0
  113. /package/{skills → dist/skills}/author/examples/review-release-notes.input.json +0 -0
  114. /package/{skills → dist/skills}/author/examples/review-release-notes.json +0 -0
@@ -0,0 +1,71 @@
1
+ // A run's decision rows as pi-durable stores them: the session document `agentrun.decisions`, one row per typed
2
+ // question the run asked, in commit order. The row is a stored format (version 1) and does not change with the
3
+ // interpreter: a host maps `@parcha/agentrun-dsl`'s `DecisionReceipt` to a row, with the facts only the host has (the
4
+ // request's size and metering, whether the node acted on the answers), and this module stores and reads rows without
5
+ // looking inside them.
6
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
7
+ import { defineDoc } from "@earendil-works/pi-durable";
8
+ /** The session document that holds a run's decision rows. */
9
+ export const DECISION_RECEIPTS_DOC = "agentrun.decisions";
10
+ /** The stored row's version. */
11
+ export const DECISION_ROW_VERSION = 1;
12
+ const DecisionsDoc = defineDoc({ kind: DECISION_RECEIPTS_DOC, version: 1, scope: "session", initial: () => ({ receipts: [] }) });
13
+ /** Commit one row, appended after every row committed before it. */
14
+ export async function appendDecision(harness, row, context = BACKGROUND_CONTEXT) {
15
+ const stored = JSON.parse(JSON.stringify(row));
16
+ await harness.commit(async (tx) => { (await tx.doc(DecisionsDoc)).receipts.push(stored); }, context);
17
+ }
18
+ /** Every row the run's Session holds, in commit order. */
19
+ export async function readDecisions(read, context = BACKGROUND_CONTEXT) {
20
+ const doc = await read.snapshot(DecisionsDoc, context);
21
+ return (doc?.receipts ?? []);
22
+ }
23
+ /** A writer that commits rows in the order they are recorded. `open` runs once, when the first row is committed, so
24
+ * a run that records no row never opens its Session here. */
25
+ export function createDecisionWriter(options) {
26
+ let session;
27
+ let chain = Promise.resolve();
28
+ let failure;
29
+ return {
30
+ record(row) {
31
+ const stored = JSON.parse(JSON.stringify(row));
32
+ chain = chain
33
+ .then(async () => appendDecision((await (session ??= options.open())).harness, stored, options.context))
34
+ .catch((error) => { failure ??= { error }; });
35
+ },
36
+ async settled() {
37
+ await chain;
38
+ if (failure)
39
+ throw failure.error;
40
+ },
41
+ async flush() {
42
+ let released;
43
+ // A row recorded while flush waits or releases joins the chain, so flush returns only when a full pass saw no new
44
+ // row. The Session is cleared before it is released: a row recorded meanwhile opens its own, which the next pass
45
+ // releases.
46
+ for (;;) {
47
+ const drained = chain;
48
+ await drained;
49
+ if (drained !== chain)
50
+ continue;
51
+ const held = session;
52
+ session = undefined;
53
+ if (held) {
54
+ const opened = await held.catch(() => undefined);
55
+ try {
56
+ await opened?.release();
57
+ }
58
+ catch (error) {
59
+ released ??= { error };
60
+ }
61
+ }
62
+ if (drained === chain)
63
+ break;
64
+ }
65
+ const first = failure ?? released;
66
+ failure = undefined;
67
+ if (first)
68
+ throw first.error;
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,20 @@
1
+ export type { RecoveryBinding, RecoveryEffect, RecoveryJournal, RecoveryNote, RecoveryStore } from "@parcha/agentrun-dsl/recovery";
2
+ export { documentStore, type DocumentStoreHost, type DocumentStoreOptions } from "./store.js";
3
+ export { committedRoutes, durableDoc, readJournal, type JournalView } from "./readers.js";
4
+ export { recordCommits, recordingLines, snapshotLines, type RecordingLine, type RecordingSource } from "./recording.js";
5
+ export { reuseReceipts, seedReceipts, ReceiptIndex, type ReceiptReuse, type ReuseReceiptsOptions, type ReusableTool } from "./receipts.js";
6
+ export { NewerTaskError, refuseNewerTasks, toolResult, ToolResultKeyError, type TextBlock } from "./pi-versions.js";
7
+ export { DECISION_RECEIPTS_DOC, DECISION_ROW_VERSION, appendDecision, createDecisionWriter, readDecisions } from "./decisions.js";
8
+ export type { DecisionRow, DecisionRowRule, DecisionSession, DecisionWriter } from "./decisions.js";
9
+ export { decisionRecovery, type DecisionRecovery } from "./decision-recovery.js";
10
+ export { DELIVERY_ATTEMPTS, deliver, deliveryEnded, deliveryTerminates, deliveryText, disagreementText, extrasNotInRecord, fileSubmission, gate, healNullSpellings, lintRecordRoot, lintRecordSchema, mixedFileSubmission, nudgeText, nullSpellings, parseStringifiedContainers, repairRecord, spend, submitFooter, VIOLATIONS_SHOWN, } from "./record.js";
11
+ export type { Delivery, DeliveryCount, DeliveryEvent, Disagreement, GateState, Reading, RecordContract, RecordState, RecordStore, Refusal, Reviewer } from "./record.js";
12
+ export { workspaceRecordFile } from "./record-file.js";
13
+ export { deliveredRecord, GateDoc, RecordDoc, recordNudge, recordStore, recordTool, spendNudge, submitDefinition } from "./record-tool.js";
14
+ export type { RecordNudgeOptions, RecordToolOptions } from "./record-tool.js";
15
+ export { closeNodeAttempt, hostScope, NodeBindingMismatch, NodeIndex, runNodeAttempt, taskScope } from "./node.js";
16
+ export type { NodeAttempt, NodeOutcome, NodeScope, NodeSettlement } from "./node.js";
17
+ export { NodeDoc, NodeFailure, nodeRunner } from "./node-runner.js";
18
+ export type { NodeHost, NodeRecord, NodeRequest } from "./node-runner.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";
@@ -0,0 +1,14 @@
1
+ export { documentStore } from "./store.js";
2
+ export { committedRoutes, durableDoc, readJournal } from "./readers.js";
3
+ export { recordCommits, recordingLines, snapshotLines } from "./recording.js";
4
+ export { reuseReceipts, seedReceipts, ReceiptIndex } from "./receipts.js";
5
+ export { NewerTaskError, refuseNewerTasks, toolResult, ToolResultKeyError } from "./pi-versions.js";
6
+ export { DECISION_RECEIPTS_DOC, DECISION_ROW_VERSION, appendDecision, createDecisionWriter, readDecisions } from "./decisions.js";
7
+ export { decisionRecovery } from "./decision-recovery.js";
8
+ export { DELIVERY_ATTEMPTS, deliver, deliveryEnded, deliveryTerminates, deliveryText, disagreementText, extrasNotInRecord, fileSubmission, gate, healNullSpellings, lintRecordRoot, lintRecordSchema, mixedFileSubmission, nudgeText, nullSpellings, parseStringifiedContainers, repairRecord, spend, submitFooter, VIOLATIONS_SHOWN, } from "./record.js";
9
+ export { workspaceRecordFile } from "./record-file.js";
10
+ export { deliveredRecord, GateDoc, RecordDoc, recordNudge, recordStore, recordTool, spendNudge, submitDefinition } from "./record-tool.js";
11
+ export { closeNodeAttempt, hostScope, NodeBindingMismatch, NodeIndex, runNodeAttempt, taskScope } from "./node.js";
12
+ export { NodeDoc, NodeFailure, nodeRunner } from "./node-runner.js";
13
+ export { createWorkflowExtension } from "./task.js";
14
+ export { createWorkflowTool, DURABLE_HOST_ADDENDUM } from "./workflow-tool.js";
@@ -0,0 +1,106 @@
1
+ import type { Context, JsonValue } from "@earendil-works/chord";
2
+ import { type AgentChange, type ConversationId, type Extension, type Tx } from "@earendil-works/pi-durable";
3
+ import type { WorkflowDeps } from "@parcha/agentrun-dsl";
4
+ import { type RecoveryNodeParams } from "@parcha/agentrun-dsl/recovery";
5
+ import { type NodeScope } from "./node.js";
6
+ import { type Disagreement, type RecordContract } from "./record.js";
7
+ import { type RecordToolOptions } from "./record-tool.js";
8
+ /** A node's configuration: what its conversation's prompt and `submit` tool are drawn from. */
9
+ export declare const NodeDoc: import("@earendil-works/pi-durable").ConversationDocToken<{
10
+ system: string;
11
+ label: string;
12
+ schema: JsonValue | null;
13
+ fileKey: string | null;
14
+ }>;
15
+ /** A node as the host is asked about it: the interpreter's request, and the attempt it runs as, with the sessions of
16
+ * the attempts before it (a host that refuses to repeat a call an earlier attempt left unknown reads them). */
17
+ export type NodeRequest = Omit<Parameters<NonNullable<WorkflowDeps["runNode"]>>[0], "review" | "signal"> & {
18
+ sessionId: string;
19
+ attempt: number;
20
+ earlierSessionIds: readonly string[];
21
+ };
22
+ /** What a host says of a node's record beside its schema. */
23
+ export type NodeRecord = Pick<RecordContract, "contracts" | "checks"> & {
24
+ /** What the model is told it delivers (default: `the <label> record`). */
25
+ label?: string;
26
+ /** The host's own delivery instructions, said with the contract that ends the node's task. */
27
+ instructions?: string;
28
+ /** The record may be delivered as a JSON file the node wrote in `workspace`, named under `key`. */
29
+ file?: {
30
+ key: string;
31
+ workspace: string;
32
+ };
33
+ };
34
+ export type NodeHost = {
35
+ /** The agent a node's conversation runs with: its model, thinking level, working directory and the host's own
36
+ * extensions and tools. The runner adds its extension and `submit` where the host did not place them. The node's
37
+ * own thinking level outranks the host's; when neither names one, the node thinks at medium (never at the level its
38
+ * conversation would copy from its owner's, and never off). */
39
+ agent(node: NodeRequest): AgentChange | Promise<AgentChange>;
40
+ /** Duty text for a node's system prompt, after the interpreter's. `offered` names the tools the agent lists. */
41
+ stance?(node: NodeRequest, offered: {
42
+ tools: readonly string[];
43
+ }): string | undefined;
44
+ record?(node: NodeRequest): NodeRecord | Promise<NodeRecord>;
45
+ /** A second reading of a node's record, before the node's own verify clause: what it disagrees with. */
46
+ verify?(record: unknown, node: NodeRequest, context: {
47
+ round: boolean;
48
+ }): Disagreement[] | Promise<Disagreement[]>;
49
+ /** Called, and awaited, when a node's conversation exists and before any request of this process reaches a model:
50
+ * the host attaches its observers to that conversation. A throw fails the node's call and releases nothing: what
51
+ * pi resumed of the node waits for the attempt's next entry, or ends with the Harness's close. */
52
+ onNodeOpen?(node: NodeRequest & {
53
+ conversationId: ConversationId;
54
+ resumed: boolean;
55
+ }): void | Promise<void>;
56
+ /** Written in the commit that creates a node's conversation, after the runner's own document: the documents the
57
+ * host's extensions read for it (a plane's configuration). */
58
+ init?(node: NodeRequest, tx: Tx, conversationId: ConversationId): void | Promise<void>;
59
+ /** Delivery attempts a node gets (default `DELIVERY_ATTEMPTS`). */
60
+ maxAttempts?: number;
61
+ /** The host's part in every `submit` call: its rows, and which errors are its own failure. The one delivery it may
62
+ * be told before `onNodeOpen` is `already`, the answer of a call that a process died inside after its commit. */
63
+ submit?: Pick<RecordToolOptions, "onDelivery" | "fatal" | "onFatal" | "fatalText">;
64
+ /** The digest an attempt is bound by. Absent, one of the node's label, path, kind, item and attempt. */
65
+ digest?(node: NodeRequest): string;
66
+ };
67
+ /** A node that delivered no record. `final` says a second attempt would spend the same again: the model did not
68
+ * answer, or the delivery attempts are spent. `unanswered` is pi's own account of a submission the model did not
69
+ * answer: its reason (`failed`, `aborted`, ...) and detail. */
70
+ export declare class NodeFailure extends Error {
71
+ readonly node: string;
72
+ readonly final: boolean;
73
+ readonly executionPath?: string | undefined;
74
+ readonly unanswered?: {
75
+ reason: string;
76
+ detail?: JsonValue;
77
+ } | undefined;
78
+ readonly code = "NODE_NOT_DELIVERED";
79
+ /** The item a map that collects its items' failures ends on this (`isItemFailure`): the node's delivery attempts are
80
+ * spent, or its model will not answer (`model_error`, as a provider's content filter). Never an abort, a stale or
81
+ * faulted run, a missing model, or a schema no record can satisfy. */
82
+ readonly itemFailure: boolean;
83
+ constructor(message: string, node: string, final: boolean, executionPath?: string | undefined, unanswered?: {
84
+ reason: string;
85
+ detail?: JsonValue;
86
+ } | undefined, itemFailure?: boolean);
87
+ }
88
+ /** The node runner over a host. Install `extension` in the registry before the Harness opens; `on` binds the runner
89
+ * to where its conversations live (`taskScope`, `hostScope`). */
90
+ export declare function nodeRunner(host: NodeHost, options?: {
91
+ name?: string;
92
+ }): {
93
+ extension: Extension;
94
+ /** The extension a conversation an older build opened still selects, under that build's name for it: the system
95
+ * text that build kept as the `task` section, and this runner's `submit`. Each of its requests and calls waits for
96
+ * the runner to reach the node, which then moves the conversation to `extension`. Install one for each node an
97
+ * older build left unfinished, before the Harness resumes. */
98
+ legacy(entry: {
99
+ extension: string;
100
+ system: string | null;
101
+ }): Extension;
102
+ on(scope: NodeScope, context: Context): {
103
+ runNode(params: RecoveryNodeParams): Promise<unknown>;
104
+ closeStepSession(sessionId: string): Promise<void>;
105
+ };
106
+ };
@@ -0,0 +1,253 @@
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 { nodeThinking } from "../thinking.js";
16
+ import { closeNodeAttempt, runNodeAttempt } from "./node.js";
17
+ import { DELIVERY_ATTEMPTS, lintRecordRoot, submitFooter } from "./record.js";
18
+ import { workspaceRecordFile } from "./record-file.js";
19
+ import { RecordDoc, recordNudge, recordTool, spendNudge, submitDefinition } from "./record-tool.js";
20
+ /** A node's configuration: what its conversation's prompt and `submit` tool are drawn from. */
21
+ export const NodeDoc = defineDoc({
22
+ kind: "agentrun.node", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ system: "", label: "", schema: null, fileKey: null }),
23
+ });
24
+ /** A node that delivered no record. `final` says a second attempt would spend the same again: the model did not
25
+ * answer, or the delivery attempts are spent. `unanswered` is pi's own account of a submission the model did not
26
+ * answer: its reason (`failed`, `aborted`, ...) and detail. */
27
+ export class NodeFailure extends Error {
28
+ node;
29
+ final;
30
+ executionPath;
31
+ unanswered;
32
+ code = "NODE_NOT_DELIVERED";
33
+ /** The item a map that collects its items' failures ends on this (`isItemFailure`): the node's delivery attempts are
34
+ * spent, or its model will not answer (`model_error`, as a provider's content filter). Never an abort, a stale or
35
+ * faulted run, a missing model, or a schema no record can satisfy. */
36
+ itemFailure;
37
+ constructor(message, node, final, executionPath, unanswered, itemFailure = false) {
38
+ super(message);
39
+ this.node = node;
40
+ this.final = final;
41
+ this.executionPath = executionPath;
42
+ this.unanswered = unanswered;
43
+ this.name = "NodeFailure";
44
+ this.itemFailure = final && itemFailure;
45
+ }
46
+ }
47
+ /** `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
48
+ * has. The listener lives only as long as the wait. */
49
+ async function underStop(signal, work) {
50
+ if (!signal)
51
+ return work();
52
+ let onStop;
53
+ const stopped = new Promise((_, reject) => { onStop = () => reject(signal.reason); });
54
+ stopped.catch(() => undefined);
55
+ if (signal.aborted)
56
+ onStop();
57
+ else
58
+ signal.addEventListener("abort", onStop, { once: true });
59
+ try {
60
+ return await Promise.race([work(), stopped]);
61
+ }
62
+ finally {
63
+ signal.removeEventListener("abort", onStop);
64
+ }
65
+ }
66
+ const listed = (value) => Array.isArray(value);
67
+ const isSubmit = (tool) => tool.name === "submit";
68
+ /** The node runner over a host. Install `extension` in the registry before the Harness opens; `on` binds the runner
69
+ * to where its conversations live (`taskScope`, `hostScope`). */
70
+ export function nodeRunner(host, options = {}) {
71
+ const maxAttempts = host.maxAttempts ?? DELIVERY_ATTEMPTS;
72
+ const attached = new Map();
73
+ const waiting = new Map();
74
+ /** What the runner attached to a conversation, once it has: the wait ends with the caller's own abort. */
75
+ const reached = (id, context) => {
76
+ const found = attached.get(Number(id));
77
+ if (found)
78
+ return Promise.resolve(found);
79
+ return new Promise((resolve, reject) => {
80
+ const waiters = waiting.get(Number(id)) ?? new Set();
81
+ waiting.set(Number(id), waiters);
82
+ const signal = context.abortSignal;
83
+ const take = (value) => { signal?.removeEventListener("abort", stopped); resolve(value); };
84
+ const stopped = () => { waiters.delete(take); reject(signal?.reason ?? new Error("the node was stopped before its runner reached it again")); };
85
+ waiters.add(take);
86
+ if (signal?.aborted)
87
+ stopped();
88
+ else
89
+ signal?.addEventListener("abort", stopped, { once: true });
90
+ });
91
+ };
92
+ const attach = (id, value) => {
93
+ attached.set(Number(id), value);
94
+ for (const take of waiting.get(Number(id)) ?? [])
95
+ take(value);
96
+ waiting.delete(Number(id));
97
+ return () => { if (attached.get(Number(id)) === value)
98
+ attached.delete(Number(id)); };
99
+ };
100
+ /** The contract of a `submit` call: a call cut after its record was committed is answered from the record and
101
+ * waits for nobody; any other waits for the runner. Only a conversation of an older build's node may be reached
102
+ * with no configuration document yet. */
103
+ const contractOf = (legacy) => async (api, context) => {
104
+ if ((await api.snapshot(RecordDoc, api.conversationId, context))?.record != null)
105
+ return { schema: {} };
106
+ if (!legacy && !(await api.snapshot(NodeDoc, api.conversationId, context))?.schema)
107
+ throw new Error("submit was called in a conversation that is no workflow node");
108
+ return (await reached(api.conversationId, context)).contract;
109
+ };
110
+ /** Each request presents `submit` as the node's own record's tool. */
111
+ const present = async (request, api, context) => {
112
+ const node = await api.snapshot(NodeDoc, api.conversationId, context);
113
+ if (!node?.schema)
114
+ return undefined;
115
+ await reached(api.conversationId, context);
116
+ const shown = submitDefinition({ schema: node.schema, label: node.label, ...(node.fileKey ? { fileKey: node.fileKey } : {}) });
117
+ return { messages: request.messages.map((message) => message.role === "system" && message.toolsAdded?.some(isSubmit)
118
+ ? { ...message, toolsAdded: message.toolsAdded.map((offered) => isSubmit(offered) ? { ...offered, ...shown } : offered) } : message) };
119
+ };
120
+ const nudge = recordNudge({
121
+ maxAttempts,
122
+ label: async (api, context) => (await api.snapshot(NodeDoc, api.conversationId, context))?.label || undefined,
123
+ spend: async (id, context) => (await reached(id, context)).commit((tx) => spendNudge(tx, id), context),
124
+ });
125
+ const tool = recordTool({ ...host.submit, contract: contractOf(false) });
126
+ const extension = defineExtension({
127
+ name: options.name ?? "agentrun-nodes",
128
+ tools: [tool],
129
+ sections: [section("task", async (input, context) => (await input.read.snapshot(NodeDoc, input.conversationId, context))?.system || undefined, { tag: false })],
130
+ hooks: [hook(GenerationTask, { beforeRequest: present }), nudge],
131
+ });
132
+ /** The host's agent at the node's thinking level. A level left unset would follow the conversation's creation: a
133
+ * task's node copies its owner's level, and a node no task owns starts off. */
134
+ const leveled = (agent, node) => ({ ...agent, thinkingLevel: nodeThinking(node.thinking, agent.thinkingLevel) });
135
+ /** The host's agent with the runner's extension first and `submit` last, unless the host placed them. */
136
+ const withNodes = (agent) => ({
137
+ ...agent,
138
+ extensions: listed(agent.extensions)
139
+ ? (agent.extensions.some((selected) => selected.name === extension.name) ? agent.extensions : [extension, ...agent.extensions])
140
+ : { ...agent.extensions, add: [extension, ...(agent.extensions?.add ?? [])] },
141
+ ...(listed(agent.tools) && !agent.tools.some(isSubmit) ? { tools: [...agent.tools, tool] } : {}),
142
+ });
143
+ return {
144
+ extension,
145
+ legacy: (entry) => defineExtension({
146
+ name: entry.extension,
147
+ tools: [recordTool({ ...host.submit, contract: contractOf(true) })],
148
+ sections: entry.system === null ? [] : [section("task", () => entry.system ?? undefined, { tag: false })],
149
+ hooks: [hook(GenerationTask, { beforeRequest: async (request, api, context) => { await reached(api.conversationId, context); return present(request, api, context); } }), nudge],
150
+ }),
151
+ on: (scope, base) => ({
152
+ closeStepSession: (sessionId) => closeNodeAttempt(scope, sessionId, base),
153
+ runNode: async (params) => {
154
+ const { review, signal, step, ...asked } = params;
155
+ // An attempt the driver admitted is found again by its session; any other call is a conversation of its own.
156
+ const node = { ...asked, sessionId: step?.sessionId ?? `unjournaled:${randomUUID()}`, attempt: step?.attempt ?? 0, earlierSessionIds: step?.earlierSessionIds ?? [] };
157
+ const fail = (message, final, unanswered, itemFailure = false) => new NodeFailure(message, node.label, final, node.executionPath, unanswered, itemFailure);
158
+ // Only a schema no submit call can satisfy is refused here: any other is the node's contract as its author wrote it.
159
+ const lint = lintRecordRoot(node.schema);
160
+ if (lint.length)
161
+ throw fail(`${node.kind} node "${node.label}" cannot deliver a record of its schema: ${lint.join("; ")}`, true);
162
+ // The host's own answers are awaited under the stop too: one that lands meanwhile opens no conversation.
163
+ const { agent, record } = await underStop(signal, async () => ({ agent: leveled(await host.agent(node), node), record: await host.record?.(node) ?? {} }));
164
+ const label = record.label ?? `the ${node.label} record`;
165
+ const offered = (listed(agent.tools) ? agent.tools : listed(agent.extensions) ? agent.extensions.flatMap((selected) => selected.tools ?? []) : [])
166
+ .map((offer) => offer.name).filter((name) => name !== "submit");
167
+ const system = [...node.system, host.stance?.(node, { tools: offered })].filter(Boolean).join("\n\n");
168
+ const reviewers = [
169
+ ...(host.verify ? [(candidate, at) => host.verify(candidate, node, at)] : []),
170
+ ...(review ? [async (candidate) => {
171
+ const verdict = await review(candidate);
172
+ return verdict.accepted ? [] : [{ id: "verify", kind: "verify", verdict: "fails", reasons: [`/: violates "the verify clause of ${node.label}" - ${verdict.message}`] }];
173
+ }] : []),
174
+ ];
175
+ // The run's stop ends the wait at once, whatever the node is doing; the attempt goes on only under a pause.
176
+ let onStop;
177
+ const stopped = new Promise((_, reject) => { onStop = () => reject(signal.reason); });
178
+ stopped.catch(() => undefined);
179
+ let opened;
180
+ const attempt = runNodeAttempt(scope, {
181
+ session: node.sessionId,
182
+ digest: host.digest?.(node) ?? canonicalSha256({ step: node.label, path: node.executionPath ?? null, kind: node.kind, item: node.item?.index ?? null, attempt: node.attempt }),
183
+ agent: withNodes(agent),
184
+ user: `${node.user}\n${submitFooter({ label, schema: node.schema, reviewed: reviewers.length > 0, ...(record.file ? { fileKey: record.file.key } : {}), ...(record.instructions ? { instructions: record.instructions } : {}) })}`,
185
+ init: async (tx, id) => {
186
+ Object.assign(await tx.doc(NodeDoc, id), { system, label, schema: node.schema, fileKey: record.file?.key ?? null });
187
+ await host.init?.(node, tx, id);
188
+ },
189
+ adopt: async (tx, id, stored) => {
190
+ const held = await tx.doc(NodeDoc, id);
191
+ if (held.schema !== null)
192
+ return;
193
+ Object.assign(held, { system: typeof stored.system === "string" ? stored.system : system, label, schema: node.schema, fileKey: record.file?.key ?? null });
194
+ // The older build selected an extension of its own for this conversation, which is not installed here.
195
+ const own = typeof stored.extension === "string" ? [{ name: stored.extension }] : [];
196
+ const given = withNodes(agent);
197
+ await configure(tx, id, { ...given, ...(listed(given.extensions) ? {} : { extensions: { ...given.extensions, remove: [...(given.extensions?.remove ?? []), ...own] } }),
198
+ ...(listed(agent.tools) ? {} : { tools: null }) });
199
+ },
200
+ onOpen: async ({ conversationId, resumed, startedMs }) => {
201
+ opened = { conversationId, detach: () => undefined };
202
+ // What pi resumed of this node waits for the attach below, so the host observes the node first. A host
203
+ // that throws here attaches nothing: that work stays waiting, for the attempt's next entry or the close.
204
+ await host.onNodeOpen?.({ ...node, conversationId, resumed });
205
+ // A stop that landed before the node's request is submitted: nothing is asked of a model.
206
+ if (signal?.aborted)
207
+ throw signal.reason;
208
+ opened.detach = attach(conversationId, { commit: scope.commit, contract: {
209
+ schema: node.schema, maxAttempts, ...(reviewers.length ? { reviewers } : {}),
210
+ ...(record.contracts ? { contracts: record.contracts } : {}), ...(record.checks ? { checks: record.checks } : {}),
211
+ ...(record.file ? { file: workspaceRecordFile({ ...record.file, notBefore: () => startedMs }) } : {}),
212
+ } });
213
+ },
214
+ }, base);
215
+ attempt.catch(() => undefined);
216
+ // Listened for from here, where the `finally` below is the only way out: no listener outlives the call.
217
+ if (signal?.aborted)
218
+ onStop();
219
+ else
220
+ signal?.addEventListener("abort", onStop, { once: true });
221
+ try {
222
+ const outcome = await Promise.race([attempt, stopped]);
223
+ if (outcome.delivered)
224
+ return outcome.delivered.record;
225
+ if (outcome.settled?.status === "unanswered") {
226
+ const { reason, detail } = outcome.settled;
227
+ // Only the model's own refusal to answer is the item's: an abort, a stale or faulted run and a missing model are the run's.
228
+ throw fail(`${node.label}: the model did not answer (${reason})`, true, { reason, ...(detail !== undefined ? { detail } : {}) }, reason === "model_error");
229
+ }
230
+ const spent = ((await scope.snapshot(RecordDoc, outcome.conversationId, base))?.attempts ?? 0) >= maxAttempts;
231
+ throw fail(`${node.label} did not submit (${spent ? "its delivery attempts are spent" : "it ended its run without a record"})`, spent, undefined, spent);
232
+ }
233
+ catch (error) {
234
+ if (!signal?.aborted)
235
+ throw error;
236
+ // A pause keeps the node's work for the process that resumes it; any other stop ends that work, and a
237
+ // request submitted as the stop landed is withdrawn once it is known.
238
+ const end = async () => { if (opened)
239
+ await (await scope.conversation(opened.conversationId, base))?.abort(base).catch(() => undefined); };
240
+ if (runStopOf(signal.reason)?.stop !== "pause") {
241
+ await end();
242
+ void attempt.then(end, end);
243
+ }
244
+ throw signal.reason ?? error;
245
+ }
246
+ finally {
247
+ signal?.removeEventListener("abort", onStop);
248
+ void attempt.then(() => opened?.detach(), () => opened?.detach());
249
+ }
250
+ },
251
+ }),
252
+ };
253
+ }
@@ -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>;