@parcha/agentrun-pi 0.1.0-beta.2 → 0.1.0-beta.20
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 +104 -27
- package/dist/cli.js +15 -4
- package/dist/demo-cli.d.ts +2 -0
- package/dist/demo-cli.js +25 -0
- package/dist/demo.js +1 -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 +20 -0
- package/dist/durable/index.js +14 -0
- package/dist/durable/node-runner.d.ts +106 -0
- package/dist/durable/node-runner.js +253 -0
- package/dist/durable/node.d.ts +88 -0
- package/dist/durable/node.js +98 -0
- package/dist/durable/pi-versions.d.ts +34 -0
- package/dist/durable/pi-versions.js +66 -0
- package/dist/durable/readers.d.ts +28 -0
- package/dist/durable/readers.js +103 -0
- package/dist/durable/receipts.d.ts +54 -0
- package/dist/durable/receipts.js +92 -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 +240 -0
- package/dist/durable/task.d.ts +133 -0
- package/dist/durable/task.js +411 -0
- package/dist/durable/workflow-tool.d.ts +32 -0
- package/dist/durable/workflow-tool.js +188 -0
- package/dist/extension-service.js +63 -22
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +469 -80
- package/dist/host-addendum.d.ts +4 -0
- package/dist/host-addendum.js +20 -0
- package/dist/host-session.js +5 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/pi-host.d.ts +7 -0
- package/dist/pi-host.js +22 -0
- package/dist/presentation.js +2 -2
- 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 +7 -13
- package/dist/signin/index.d.ts +9 -0
- package/dist/signin/index.js +7 -0
- package/dist/signin/limit.d.ts +10 -0
- package/dist/signin/limit.js +23 -0
- package/dist/signin/resolve.d.ts +47 -0
- package/dist/signin/resolve.js +163 -0
- package/dist/signin/signin.d.ts +44 -0
- package/dist/signin/signin.js +126 -0
- package/dist/signin/spec.d.ts +116 -0
- package/dist/signin/spec.js +181 -0
- package/dist/signin/store.d.ts +40 -0
- package/dist/signin/store.js +206 -0
- package/dist/skills/author/SKILL.md +138 -0
- package/{skills → dist/skills}/author/references/jev-decisions.md +27 -14
- package/dist/skills/author/references/language.md +134 -0
- package/dist/skills/author/references/sdk-authoring.md +11 -0
- package/{skills → dist/skills}/author/references/workflow-format.md +7 -15
- package/dist/sop.d.ts +12 -0
- package/dist/sop.js +29 -0
- package/dist/thinking.d.ts +3 -0
- package/dist/thinking.js +7 -0
- package/dist/tool-context.d.ts +21 -0
- package/dist/tool-context.js +13 -0
- package/dist/triage-demo.d.ts +62 -0
- package/dist/triage-demo.js +153 -0
- package/dist/types.d.ts +7 -0
- package/dist/workflow-inspector.d.ts +47 -0
- package/dist/workflow-inspector.js +235 -0
- package/dist/workflow-session.d.ts +23 -0
- package/dist/workflow-session.js +168 -0
- package/dist/workflow-store.d.ts +36 -0
- package/dist/workflow-store.js +255 -0
- package/dist/workflow-tool.d.ts +81 -0
- package/dist/workflow-tool.js +94 -0
- package/dist/workflow-view.d.ts +64 -0
- package/dist/workflow-view.js +273 -0
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.js +3 -0
- package/package.json +57 -11
- package/dist/admission.d.ts +0 -6
- package/dist/admission.js +0 -51
- package/dist/author.d.ts +0 -21
- package/dist/author.js +0 -72
- package/dist/benchmark-contract.d.ts +0 -88
- package/dist/benchmark-contract.js +0 -50
- package/dist/benchmark-freeze.d.ts +0 -9
- package/dist/benchmark-freeze.js +0 -9
- package/dist/benchmark.d.ts +0 -127
- package/dist/benchmark.js +0 -260
- package/dist/comparison.d.ts +0 -69
- package/dist/comparison.js +0 -229
- package/dist/skill-bundle.d.ts +0 -5
- package/dist/skill-bundle.js +0 -15
- package/skills/author/SKILL.md +0 -102
- package/skills/author/references/sdk-authoring.md +0 -16
- /package/{skills → dist/skills}/author/examples/evidence-gate.input.json +0 -0
- /package/{skills → dist/skills}/author/examples/evidence-gate.json +0 -0
- /package/{skills → dist/skills}/author/examples/read-file.input.json +0 -0
- /package/{skills → dist/skills}/author/examples/read-file.json +0 -0
- /package/{skills → dist/skills}/author/examples/read-source-decision.input.json +0 -0
- /package/{skills → dist/skills}/author/examples/read-source-decision.json +0 -0
- /package/{skills → dist/skills}/author/examples/release-note.txt +0 -0
- /package/{skills → dist/skills}/author/examples/review-release-notes.input.json +0 -0
- /package/{skills → dist/skills}/author/examples/review-release-notes.json +0 -0
|
@@ -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>;
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
/** The attempt's conversation when another task owns it: that task ended, and this one replaced it. */
|
|
43
|
+
let orphaned;
|
|
44
|
+
/** A new conversation for the attempt, owned by the scope's task, recorded under the attempt's session. */
|
|
45
|
+
const create = async (tx, entry) => {
|
|
46
|
+
const conversation = await tx.createConversation({ ownership: scope.owner !== undefined ? { kind: "task", taskId: scope.owner } : { kind: "ownerless" } });
|
|
47
|
+
await configure(tx, conversation.id, attempt.agent);
|
|
48
|
+
await attempt.init?.(tx, conversation.id);
|
|
49
|
+
entry.conversation = Number(conversation.id);
|
|
50
|
+
entry.digest = attempt.digest;
|
|
51
|
+
entry.startedMs = Date.now();
|
|
52
|
+
entry.closed = false;
|
|
53
|
+
found = { conversationId: conversation.id, digest: attempt.digest, resumed: false, startedMs: entry.startedMs };
|
|
54
|
+
};
|
|
55
|
+
await scope.commit(async (tx) => {
|
|
56
|
+
const entry = await tx.doc(NodeIndex, attempt.session, null);
|
|
57
|
+
if (entry.conversation !== null) {
|
|
58
|
+
found = { conversationId: entry.conversation, digest: entry.digest, resumed: true, startedMs: entry.startedMs ?? 0 };
|
|
59
|
+
// An attempt under another binding is refused below, untouched.
|
|
60
|
+
if (entry.digest === attempt.digest)
|
|
61
|
+
await attempt.adopt?.(tx, found.conversationId, entry);
|
|
62
|
+
if (scope.owner !== undefined && entry.digest === attempt.digest) {
|
|
63
|
+
const owner = (await tx.conversation(found.conversationId))?.owner?.taskId;
|
|
64
|
+
if (owner !== undefined && owner !== scope.owner)
|
|
65
|
+
orphaned = found.conversationId;
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await create(tx, entry);
|
|
70
|
+
}, context);
|
|
71
|
+
if (found.digest !== attempt.digest)
|
|
72
|
+
throw new NodeBindingMismatch(attempt.session, found.digest, attempt.digest);
|
|
73
|
+
// The replaced task's conversation: its record, when it delivered one, is the node's; otherwise the attempt runs
|
|
74
|
+
// again in a conversation of this task, never on the request the old task's end aborted.
|
|
75
|
+
if (orphaned !== undefined && !(await deliveredRecord(scope, orphaned, context))) {
|
|
76
|
+
await scope.commit(async (tx) => { await create(tx, await tx.doc(NodeIndex, attempt.session, null)); }, context);
|
|
77
|
+
}
|
|
78
|
+
const { conversationId, resumed, startedMs } = found;
|
|
79
|
+
await attempt.onOpen?.({ conversationId, resumed, startedMs });
|
|
80
|
+
// A record the attempt already delivered is the node's record: nothing is requested for it.
|
|
81
|
+
const already = await deliveredRecord(scope, conversationId, context);
|
|
82
|
+
if (already)
|
|
83
|
+
return { conversationId, resumed, delivered: already };
|
|
84
|
+
const handle = await scope.conversation(conversationId, context);
|
|
85
|
+
if (!handle)
|
|
86
|
+
throw new Error(`node attempt ${attempt.session} names conversation ${String(conversationId)}, which does not exist`);
|
|
87
|
+
const submission = await handle.submit({ type: "input", content: attempt.user, requestId: `node:${attempt.session}` }, context);
|
|
88
|
+
const ended = await submission.wait(context);
|
|
89
|
+
const settled = ended.status === "unanswered"
|
|
90
|
+
? { status: "unanswered", reason: ended.reason, ...(ended.detail !== undefined ? { detail: ended.detail } : {}) } : { status: "done" };
|
|
91
|
+
const delivered = await deliveredRecord(scope, conversationId, context);
|
|
92
|
+
return { conversationId, resumed, settled, ...(delivered ? { delivered } : {}) };
|
|
93
|
+
}
|
|
94
|
+
/** An attempt its owner closed without a record: the mark a reader tells it from one still running by. */
|
|
95
|
+
export async function closeNodeAttempt(scope, session, context) {
|
|
96
|
+
await scope.commit(async (tx) => { const entry = await tx.doc(NodeIndex, session, null); if (entry.conversation !== null)
|
|
97
|
+
entry.closed = true; }, context);
|
|
98
|
+
}
|
|
@@ -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>;
|