@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,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>;
@@ -0,0 +1,66 @@
1
+ // What differs between the pi-durable versions this package supports (1.0.4 to 1.2.x), in one place.
2
+ //
3
+ // A tool result's blocks are `content` up to 1.1 and `output` from 1.2. Each version reads its own key, ignores the other
4
+ // and stores only what it read, so a result that carries both is read the same by all of them. A result that carries only
5
+ // the other version's key reaches the model empty, with no error from pi: where this package receives a result it did not
6
+ // write, that is refused here. A storage error fails a 1.2 Session, and its later calls reject with `SessionFailed`
7
+ // whose cause is that error; 1.1 rejected with the error itself. No pi-durable name that one of the versions lacks is
8
+ // imported by name, since a missing named export fails the module at load.
9
+ import * as piDurable from "@earendil-works/pi-durable";
10
+ /** The key the loaded pi-durable reads a tool result's blocks from. 1.2 renamed `content` to `output` in the release
11
+ * that added `SessionFailed`, so that export tells the two apart. */
12
+ export const RESULT_KEY = "SessionFailed" in piDurable ? "output" : "content";
13
+ const OTHER_KEY = RESULT_KEY === "output" ? "content" : "output";
14
+ /** A tool result every supported pi-durable reads as `blocks`: the blocks under both keys, each its own copy. */
15
+ export function toolResult(blocks, fields = {}) {
16
+ return { ...fields, content: blocks.map((block) => ({ ...block })), output: blocks.map((block) => ({ ...block })) };
17
+ }
18
+ /** A tool result whose blocks the loaded pi-durable would not read: it reached the model empty. */
19
+ export class ToolResultKeyError extends Error {
20
+ tool;
21
+ code = "TOOL_RESULT_KEY";
22
+ constructor(tool) {
23
+ super(`${tool} returned its result under "${OTHER_KEY}", and the loaded pi-durable reads "${RESULT_KEY}": the model would receive an empty result. Return the blocks under "${RESULT_KEY}", or under both keys.`);
24
+ this.tool = tool;
25
+ this.name = "ToolResultKeyError";
26
+ }
27
+ }
28
+ /** The blocks of a result `tool` returned, as the loaded pi-durable reads them; undefined for a result with no blocks
29
+ * (pi then shows what the tool streamed). A result whose blocks are only under the other version's key throws
30
+ * `ToolResultKeyError`. */
31
+ export function resultBlocks(tool, result) {
32
+ if (!result || typeof result !== "object")
33
+ return undefined;
34
+ const own = result[RESULT_KEY];
35
+ if (own !== undefined)
36
+ return Array.isArray(own) ? own : undefined;
37
+ if (result[OTHER_KEY] !== undefined)
38
+ throw new ToolResultKeyError(tool);
39
+ return undefined;
40
+ }
41
+ /** The storage error behind a Session that failed at one (`SessionFailed` on 1.2, matched by name), else the error. */
42
+ export function storageError(error) {
43
+ return error instanceof Error && error.name === "SessionFailed" && error.cause !== undefined ? error.cause : error;
44
+ }
45
+ /** A live task of a newer pi-durable than the loaded one: pi-durable 1.2 keeps a tool call in flight as a version 2
46
+ * `pi.tool` task, which an earlier version waits on without running. */
47
+ export class NewerTaskError extends Error {
48
+ kind;
49
+ version;
50
+ code = "PI_TASK_NEWER";
51
+ constructor(kind, version) {
52
+ super(`A live ${kind} task is version ${version}, which pi-durable 1.2 or later runs and the loaded pi-durable (before 1.2) cannot: continue the run on pi-durable 1.2 or later.`);
53
+ this.kind = kind;
54
+ this.version = version;
55
+ this.name = "NewerTaskError";
56
+ }
57
+ }
58
+ /** Refuse a store a newer pi-durable left with a tool call in flight, instead of waiting on it: a host calls this after
59
+ * opening its Harness and before resuming it. On pi-durable 1.2 it does nothing. */
60
+ export async function refuseNewerTasks(harness, context) {
61
+ if (RESULT_KEY === "output")
62
+ return;
63
+ const newer = (await harness.inspect(context)).tasks.find(({ record }) => record.kind === "pi.tool" && record.version > 1)?.record;
64
+ if (newer)
65
+ throw new NewerTaskError(newer.kind, newer.version);
66
+ }
@@ -0,0 +1,28 @@
1
+ import type { RecoveryEffect, RecoveryNote } from "@parcha/agentrun-dsl/recovery";
2
+ type Database = import("node:sqlite").DatabaseSync;
3
+ type Json = any;
4
+ /** A journal read back from the file: what `documentStore` keeps under one key. */
5
+ export type JournalView = {
6
+ binding: string;
7
+ inputs: Record<string, string> | null;
8
+ generation: number;
9
+ revision: number;
10
+ state: Json;
11
+ notes: RecoveryNote[];
12
+ effects: RecoveryEffect[];
13
+ };
14
+ /** The run's store `file`, opened read-only for `read`; null when there is no such file. Every query of one `read` runs
15
+ * in one read transaction, so what it returns is one commit's view, whatever a writer commits meanwhile. */
16
+ export declare function withDurableFile<T>(file: string, read: (db: Database) => T): T | null;
17
+ /** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
18
+ export declare function durableDoc<T = Record<string, unknown>>(file: string, kind: string, key?: string): T | undefined;
19
+ /** The journal `key` holds in the run's store `file`, as the last commit left it; undefined when the file holds no
20
+ * such journal or none has bound it. `effects` are every effect the journal admitted, once each, in the order they were
21
+ * admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
22
+ * included, are listed by that item's record, not the driver's, and are read from there. `state` is the driver's own
23
+ * record: in the keyed layout what lies in each map item stays in the item's record. */
24
+ export declare function readJournal(file: string, key: string): JournalView | undefined;
25
+ /** Every committed route decision a frozen snapshot holds (each judge's answer, whole), by execution path. Empty for a
26
+ * state that is no snapshot. */
27
+ export declare function committedRoutes(state: unknown): Record<string, Json>;
28
+ export {};
@@ -0,0 +1,103 @@
1
+ // Read-only views of a run's durable file, synchronously and without the Harness: a document by its address, a
2
+ // journal as `documentStore` wrote it, and the route decisions a frozen snapshot holds. A reader that only looks at
3
+ // what ran (the run card, a tail that inherits what a run decided) uses them; the Harness stays the only writer. The
4
+ // readers take the store file itself (pi-durable's SQLite file, wherever the host keeps it), never a directory.
5
+ import fs from "node:fs";
6
+ import { createRequire } from "node:module";
7
+ import { apply } from "@earendil-works/chord/delta";
8
+ import { effectKey } from "./store.js";
9
+ /** The run's store `file`, opened read-only for `read`; null when there is no such file. Every query of one `read` runs
10
+ * in one read transaction, so what it returns is one commit's view, whatever a writer commits meanwhile. */
11
+ export function withDurableFile(file, read) {
12
+ if (!fs.existsSync(file))
13
+ return null;
14
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
15
+ const db = new DatabaseSync(file, { readOnly: true, timeout: 5000 });
16
+ try {
17
+ db.exec("BEGIN");
18
+ return read(db);
19
+ }
20
+ finally {
21
+ db.close();
22
+ }
23
+ }
24
+ /** A document's value: its latest base with every later delta applied, as the storage materializes it. */
25
+ function revisionsOf(db, id) {
26
+ const base = db.prepare("SELECT seq, content FROM document_revisions WHERE document_id = ? AND kind = 'base' ORDER BY seq DESC LIMIT 1").get(id);
27
+ if (!base)
28
+ return undefined;
29
+ let value = JSON.parse(base.content);
30
+ for (const delta of db.prepare("SELECT content FROM document_revisions WHERE document_id = ? AND seq > ? ORDER BY seq").all(id, base.seq)) {
31
+ value = apply(value, JSON.parse(delta.content));
32
+ }
33
+ return value;
34
+ }
35
+ /** The row of a Session document. `key` names a family member; a singleton has none. */
36
+ function sessionDocId(db, kind, key) {
37
+ const row = db.prepare("SELECT id FROM documents WHERE kind = ? AND scope_kind = 'session' AND owner_id = 0 AND family = ? AND key_value = ? AND retired_at IS NULL ORDER BY created_at DESC LIMIT 1")
38
+ .get(JSON.stringify(kind), key === undefined ? 0 : 1, JSON.stringify(key ?? ""));
39
+ return row?.id;
40
+ }
41
+ /** A Session document's current value. `key` names a family member; a singleton has none. */
42
+ function sessionDoc(db, kind, key) {
43
+ const id = sessionDocId(db, kind, key);
44
+ return id === undefined ? undefined : revisionsOf(db, id);
45
+ }
46
+ /** The ids of the effects the map items of `table` own (the keyed layout's `maps`: `{ <map path>: { items, ... } }`), each
47
+ * item's own nested maps included, read as the driver reads them back: item `i` of a map is its record at
48
+ * `<map path>/items/<i>`, for every item the map started. */
49
+ function itemEffects(db, key, table, into) {
50
+ if (!table || typeof table !== "object" || Array.isArray(table))
51
+ return;
52
+ for (const [mapPath, entry] of Object.entries(table)) {
53
+ const started = entry?.items;
54
+ if (typeof started !== "number" || !Number.isSafeInteger(started))
55
+ continue;
56
+ for (let index = 0; index < started; index += 1) {
57
+ const item = sessionDoc(db, "agentrun.items", effectKey(key, `${mapPath}/items/${index}`));
58
+ if (!item)
59
+ continue;
60
+ if (Array.isArray(item.effects))
61
+ for (const id of item.effects)
62
+ if (typeof id === "string")
63
+ into.push(id);
64
+ itemEffects(db, key, item.maps, into);
65
+ }
66
+ }
67
+ }
68
+ /** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
69
+ export function durableDoc(file, kind, key) {
70
+ return (withDurableFile(file, (db) => sessionDoc(db, kind, key)) ?? undefined);
71
+ }
72
+ /** The journal `key` holds in the run's store `file`, as the last commit left it; undefined when the file holds no
73
+ * such journal or none has bound it. `effects` are every effect the journal admitted, once each, in the order they were
74
+ * admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
75
+ * included, are listed by that item's record, not the driver's, and are read from there. `state` is the driver's own
76
+ * record: in the keyed layout what lies in each map item stays in the item's record. */
77
+ export function readJournal(file, key) {
78
+ return withDurableFile(file, (db) => {
79
+ const doc = sessionDoc(db, "agentrun.driver", key);
80
+ if (!doc || doc.binding === null)
81
+ return undefined;
82
+ // The driver's record lists the effects no map item owns; in the keyed layout each item's record lists its own. All of
83
+ // them, once each, in the order they were admitted: an effect's document is created by its admission, and a document's
84
+ // row is numbered when it is created (a later checkpoint folds its revisions, never its row).
85
+ const ids = [...doc.effects];
86
+ itemEffects(db, key, doc.state?.maps, ids);
87
+ const admitted = new Map();
88
+ for (const id of new Set(ids))
89
+ admitted.set(id, sessionDocId(db, "agentrun.effects", effectKey(key, id)) ?? Number.MAX_SAFE_INTEGER);
90
+ // A stable sort: an id whose document is missing keeps its place in the listing, after every admitted one.
91
+ const effects = [...admitted.keys()].sort((a, b) => admitted.get(a) - admitted.get(b)).map((id) => {
92
+ const e = sessionDoc(db, "agentrun.effects", effectKey(key, id));
93
+ return { id, name: e?.name ?? "", argsHash: e?.argsHash ?? "", status: e?.status ?? "unknown", session: e?.session ?? null, result: e?.result ?? null, ...(e?.intent ? { intent: e.intent } : {}), ...(e?.args !== undefined ? { args: e.args } : {}), ...(Array.isArray(e?.calls) ? { calls: e.calls } : {}) };
94
+ });
95
+ return { binding: doc.binding, inputs: doc.inputs, generation: doc.generation, revision: doc.revision, state: doc.state, notes: doc.notes, effects };
96
+ }) ?? undefined;
97
+ }
98
+ /** Every committed route decision a frozen snapshot holds (each judge's answer, whole), by execution path. Empty for a
99
+ * state that is no snapshot. */
100
+ export function committedRoutes(state) {
101
+ const routes = state?.pin?.routes;
102
+ return routes && typeof routes === "object" && !Array.isArray(routes) ? { ...routes } : {};
103
+ }
@@ -0,0 +1,54 @@
1
+ import type { Context, JsonValue } from "@earendil-works/chord";
2
+ import { type InheritedReceipt, type InheritedUnknown } from "@parcha/agentrun-dsl/recovery";
3
+ import type { DocumentStoreHost } from "./store.js";
4
+ /** The run's paid-effect receipts, one per external call (the tool and the hash of its arguments): what the call answered
5
+ * and which session paid for it. A record of spend, never a stop. Every session of the run is in the same Session, so a
6
+ * continuation reads its predecessor's receipts here, with no copy at the handoff beyond the seed. A receipt holds the
7
+ * result's text: the text blocks, joined by a newline. Another kind of block, an image for one, is not kept, and a repeat
8
+ * is answered with the text alone. A call that returned nothing is a receipt too (its result reads `null`), so a repeat is
9
+ * answered and not paid for again. The kind, version and key are a stored format. */
10
+ export declare const ReceiptIndex: import("@earendil-works/pi-durable").SessionDocFamilyToken<{
11
+ tool: string;
12
+ argsHash: string;
13
+ result: JsonValue | null;
14
+ session: string | null;
15
+ }, null>;
16
+ /** Put a journal's paid receipts into the run's receipts before a continuation's first request. A receipt already held
17
+ * is kept. A receipt with no session is the run `from` paid for. */
18
+ export declare function seedReceipts(host: DocumentStoreHost, receipts: readonly Pick<InheritedReceipt, "tool" | "argsHash" | "result" | "session">[], from: string, context?: Context): Promise<void>;
19
+ /** The part of a durable tool the wrapper reads and replaces: `execute` gets the call's arguments, pi-durable's call api
20
+ * (`snapshot` and `commit` on the run's documents) and the call's context. */
21
+ export type ReusableTool = {
22
+ name: string;
23
+ execute: (args: any, api: any, context: any) => Promise<any>;
24
+ };
25
+ export type ReuseReceiptsOptions = {
26
+ /** The tools whose calls are paid and keep a receipt: a tool whose answer is its own session's state keeps none. */
27
+ tools: readonly string[];
28
+ /** The session a call belongs to: a call is never answered from a receipt its own session wrote. Absent, each call's
29
+ * own conversation, so a node run again in another conversation (its task replaced after it failed) is answered from
30
+ * the receipt the conversation it replaces paid for, and does not pay again. */
31
+ sessionId?: string;
32
+ /** Whether a repeat of another session's paid call is answered from its receipt (a continuation's sessions do). Receipts
33
+ * are recorded either way. */
34
+ answer: boolean;
35
+ /** Effects whose outcome is unknown, from the handoff (`handoff.unknown`): a call equal to one is refused. */
36
+ unknown?: readonly Pick<InheritedUnknown, "name" | "argsHash" | "intent">[];
37
+ /** How a call's key is derived. Default: the gateway tool a `fetch` wrapper names, else the tool's own name. */
38
+ keyOf?: (toolName: string, args: unknown) => {
39
+ tool: string;
40
+ argsHash: string;
41
+ };
42
+ };
43
+ export type ReceiptReuse = {
44
+ /** The tool with its `execute` wrapped; a tool not named in `tools` is returned as it is. A wrapped call whose result
45
+ * carries its blocks only under the key the loaded pi-durable does not read throws `ToolResultKeyError`, and no
46
+ * receipt is written: the model would have received an empty result. */
47
+ wrap<T extends ReusableTool>(tool: T): T;
48
+ /** Calls answered from a receipt and calls refused as unknown, so far. */
49
+ readonly stats: {
50
+ reused: number;
51
+ refused: number;
52
+ };
53
+ };
54
+ export declare function reuseReceipts(options: ReuseReceiptsOptions): ReceiptReuse;
@@ -0,0 +1,92 @@
1
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
+ import { defineDocFamily } from "@earendil-works/pi-durable";
3
+ import { gatewayIntentOf, returnedFailure } from "@parcha/agentrun-dsl/recovery";
4
+ import { resultBlocks, toolResult } from "./pi-versions.js";
5
+ // Not buying an effect twice, structurally. A pi hook cannot answer a call without executing it, so a run that pays for
6
+ // external calls wraps each paid tool: the call's paid result is recorded in the run's receipts, a repeat of a call another
7
+ // session of the run paid for is answered from them and says so, and a call equal to an effect whose outcome is unknown
8
+ // is refused. A continuation seeds the receipts from the handoff before its first request. The wrapper needs no pi tool
9
+ // type: it decorates anything with a name and an `execute`, so a host applies it through pi-durable's `wrapTool` or to its
10
+ // own registrations.
11
+ /** The run's paid-effect receipts, one per external call (the tool and the hash of its arguments): what the call answered
12
+ * and which session paid for it. A record of spend, never a stop. Every session of the run is in the same Session, so a
13
+ * continuation reads its predecessor's receipts here, with no copy at the handoff beyond the seed. A receipt holds the
14
+ * result's text: the text blocks, joined by a newline. Another kind of block, an image for one, is not kept, and a repeat
15
+ * is answered with the text alone. A call that returned nothing is a receipt too (its result reads `null`), so a repeat is
16
+ * answered and not paid for again. The kind, version and key are a stored format. */
17
+ export const ReceiptIndex = defineDocFamily({
18
+ kind: "agentrun.receipts", version: 1, scope: "session", family: true, initial: () => ({ tool: "", argsHash: "", result: null, session: null }),
19
+ });
20
+ /** Put a journal's paid receipts into the run's receipts before a continuation's first request. A receipt already held
21
+ * is kept. A receipt with no session is the run `from` paid for. */
22
+ export async function seedReceipts(host, receipts, from, context = BACKGROUND_CONTEXT) {
23
+ if (!receipts.length)
24
+ return;
25
+ await host.commit(async (tx) => {
26
+ for (const receipt of receipts) {
27
+ const held = await tx.doc(ReceiptIndex, `${receipt.tool}:${receipt.argsHash}`, null);
28
+ if (held.result !== null)
29
+ continue;
30
+ held.tool = receipt.tool;
31
+ held.argsHash = receipt.argsHash;
32
+ held.session = receipt.session ?? from;
33
+ held.result = (typeof receipt.result === "string" ? receipt.result : JSON.stringify(receipt.result ?? null));
34
+ }
35
+ }, context);
36
+ }
37
+ /** The text of a result's blocks, joined by a newline: what a receipt holds. */
38
+ const textOf = (blocks) => blocks.map((block) => block?.text ?? "").join("\n");
39
+ /** A returned result that is itself a failure is no paid result: `isError`, or a failure envelope as the result itself,
40
+ * its JSON text, or the text of its blocks (`blocks`, as the loaded pi-durable reads them). */
41
+ const failed = (raw, blocks) => {
42
+ if (raw && typeof raw === "object" && raw.isError === true)
43
+ return true;
44
+ if (blocks)
45
+ return returnedFailure(textOf(blocks));
46
+ return returnedFailure(raw);
47
+ };
48
+ export function reuseReceipts(options) {
49
+ const keyOf = options.keyOf ?? gatewayIntentOf;
50
+ // An unknown is matched by the name and hash its admission recorded, and by the external call it was admitted for.
51
+ const unknown = new Set((options.unknown ?? []).flatMap((u) => [`${u.name}\n${u.argsHash}`, ...(u.intent ? [`${u.intent.tool}\n${u.intent.argsHash}`] : [])]));
52
+ const paid = new Set(options.tools);
53
+ const stats = { reused: 0, refused: 0 };
54
+ return {
55
+ stats,
56
+ wrap: (tool) => !paid.has(tool.name) ? tool : {
57
+ ...tool,
58
+ execute: async (args, api, context) => {
59
+ const intent = keyOf(tool.name, args);
60
+ if (unknown.has(`${intent.tool}\n${intent.argsHash}`) || unknown.has(`${tool.name}\n${intent.argsHash}`)) {
61
+ stats.refused += 1;
62
+ return toolResult([{ type: "text", text: `${tool.name} is not called again: an identical earlier call was cut while it ran, so what it did outside is unknown. Work from what you have, or make a different call.` }], { isError: true, details: { effect_status: "unknown", refused: true } });
63
+ }
64
+ const key = `${intent.tool}:${intent.argsHash}`;
65
+ const session = options.sessionId ?? String(api?.conversationId);
66
+ if (options.answer) {
67
+ const receipt = await api.snapshot(ReceiptIndex, key, context);
68
+ if (receipt?.result != null && receipt.session !== session) {
69
+ stats.reused += 1;
70
+ const text = typeof receipt.result === "string" ? receipt.result : JSON.stringify(receipt.result);
71
+ return toolResult([{ type: "text", text: `${text}\n\n(answered from the paid receipt of ${receipt.session}; the call was not dispatched again)` }], { details: { effect_status: "reused", source_session: receipt.session } });
72
+ }
73
+ }
74
+ const result = await tool.execute(args, api, context);
75
+ const blocks = resultBlocks(tool.name, result);
76
+ if (!failed(result, blocks)) {
77
+ const value = textOf(blocks ?? []);
78
+ await api.commit(async (tx) => {
79
+ const receipt = await tx.doc(ReceiptIndex, key, null);
80
+ if (receipt.result === null) {
81
+ receipt.tool = intent.tool;
82
+ receipt.argsHash = intent.argsHash;
83
+ receipt.result = value;
84
+ receipt.session = session;
85
+ }
86
+ }, context);
87
+ }
88
+ return result;
89
+ },
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,10 @@
1
+ import type { RecordContract } from "./record.js";
2
+ /** The file envelope over a workspace directory, under the key the host names. `notBefore` is when the node's
3
+ * conversation first opened, in epoch milliseconds: an older file is another run's and is refused. A file that is
4
+ * not there fails with the file system's own `ENOENT`. A workspace that is the file system's root confines nothing,
5
+ * and every path is refused there as outside it. */
6
+ export declare function workspaceRecordFile(options: {
7
+ key: string;
8
+ workspace: string;
9
+ notBefore?: () => number;
10
+ }): NonNullable<RecordContract["file"]>;
@@ -0,0 +1,56 @@
1
+ // A record delivered as a workspace file: the reader behind the file envelope. The path is the model's, so it is
2
+ // held to the workspace (a relative `.json` path, no symlink on the way, a regular file) and to this run (written
3
+ // after the node's conversation opened), and the file must parse.
4
+ import { constants } from "node:fs";
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ // A file system's modification times can trail the clock slightly; a file the node just wrote is not stale.
8
+ const CLOCK_SKEW_MS = 2_000;
9
+ /** The file envelope over a workspace directory, under the key the host names. `notBefore` is when the node's
10
+ * conversation first opened, in epoch milliseconds: an older file is another run's and is refused. A file that is
11
+ * not there fails with the file system's own `ENOENT`. A workspace that is the file system's root confines nothing,
12
+ * and every path is refused there as outside it. */
13
+ export function workspaceRecordFile(options) {
14
+ const { key } = options;
15
+ return {
16
+ key,
17
+ read: async (relative) => {
18
+ if (path.isAbsolute(relative) || path.extname(relative).toLowerCase() !== ".json")
19
+ throw new Error(`${key} must name a workspace-relative .json file`);
20
+ const root = path.resolve(options.workspace);
21
+ const target = path.resolve(root, relative);
22
+ if (!target.startsWith(`${root}${path.sep}`))
23
+ throw new Error(`${key} names a path outside the workspace: ${relative}`);
24
+ // Lexical containment alone would follow a symlink in the workspace out of it.
25
+ let cursor = root;
26
+ for (const segment of path.relative(root, target).split(path.sep).filter(Boolean)) {
27
+ cursor = path.join(cursor, segment);
28
+ const stat = await fs.lstat(cursor).catch(() => null);
29
+ if (!stat)
30
+ break;
31
+ if (stat.isSymbolicLink())
32
+ throw new Error(`${key} names a path outside the workspace through a symlink: ${relative}`);
33
+ }
34
+ // Non-blocking: a named pipe opens at once and is refused below, where a plain open would wait for its writer.
35
+ const handle = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
36
+ let text;
37
+ try {
38
+ const stat = await handle.stat();
39
+ if (!stat.isFile())
40
+ throw new Error(`${key} must name a regular file`);
41
+ if (options.notBefore && stat.mtimeMs < options.notBefore() - CLOCK_SKEW_MS)
42
+ throw new Error(`${key} is stale; write it during this run before submitting`);
43
+ text = await handle.readFile({ encoding: "utf8" });
44
+ }
45
+ finally {
46
+ await handle.close();
47
+ }
48
+ try {
49
+ return JSON.parse(text);
50
+ }
51
+ catch (error) {
52
+ throw new Error(`${key} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
53
+ }
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,76 @@
1
+ import type { Context, JsonValue } from "@earendil-works/chord";
2
+ import { type ConversationId, type DocumentReader, type HookApi, type HookRegistration, type ToolExecutionApi, type ToolRegistration } from "@earendil-works/pi-durable";
3
+ import { type Delivery, type Disagreement, type RecordContract, type RecordStore } from "./record.js";
4
+ /** The record a conversation delivered, or null while it has not, and the delivery attempts it has spent. */
5
+ export declare const RecordDoc: import("@earendil-works/pi-durable").ConversationDocToken<{
6
+ record: JsonValue | null;
7
+ attempts: number;
8
+ }>;
9
+ /** The gate's own state beside the record: whether the second reading's round was spent, and what it still
10
+ * disagreed with in the delivered record. */
11
+ export declare const GateDoc: import("@earendil-works/pi-durable").ConversationDocToken<{
12
+ bounced: boolean;
13
+ disagreements: Disagreement[];
14
+ }>;
15
+ /** The core's store on a conversation's two documents, written through a tool call's commits. */
16
+ export declare function recordStore(api: Pick<ToolExecutionApi, "commit" | "snapshot" | "conversationId">, context: Context): RecordStore;
17
+ /** The record a conversation delivered, or undefined while it has not, with what its second reading still disagreed with. */
18
+ export declare function deliveredRecord(reader: Pick<DocumentReader, "snapshot">, conversationId: ConversationId, context: Context): Promise<{
19
+ record: JsonValue;
20
+ disagreements: Disagreement[];
21
+ } | undefined>;
22
+ /** The `submit` tool as a model is shown it for one record: what it delivers, and the record's top-level fields with
23
+ * their plain types and descriptions, none required, since a partial record is an attempt the core counts. A record
24
+ * that may be delivered as a workspace file is offered the envelope under `fileKey`. */
25
+ export declare function submitDefinition(record: {
26
+ schema: Record<string, unknown>;
27
+ label: string;
28
+ fileKey?: string;
29
+ }): {
30
+ description: string;
31
+ parameters: Record<string, unknown>;
32
+ };
33
+ export type RecordToolOptions = {
34
+ /** The one record this tool delivers: its schema, what the model is told it delivers ("the summary record") and
35
+ * the key it may be delivered under as a workspace file. Absent, the tool serves conversations with different
36
+ * records: it is registered with open parameters, each conversation's contract names its schema and file key, and
37
+ * the host presents each conversation its own definition (`submitDefinition`) in a request hook. */
38
+ record?: {
39
+ schema: Record<string, unknown>;
40
+ label: string;
41
+ fileKey?: string;
42
+ };
43
+ /** The delivery contract of the conversation that called: its schema, reviewers, host checks and file reader. It is
44
+ * read on every call, so a host may resolve it late (a conversation pi resumed before its owner reached it again). */
45
+ contract(api: Pick<ToolExecutionApi, "conversationId" | "snapshot">, context: Context): RecordContract | Promise<RecordContract>;
46
+ /** Told each delivery after its commit: a host writes its own rows from it. A throw is the caller's own failure. */
47
+ onDelivery?(delivery: Delivery, conversationId: ConversationId): void | Promise<void>;
48
+ /** Whether an error is the host's own failure. Such an error ends the node's run with `fatalText`, and the host is
49
+ * told through `onFatal`; any other error is the tool's and is thrown. */
50
+ fatal?: (error: unknown) => boolean;
51
+ onFatal?(error: unknown, conversationId: ConversationId): void;
52
+ fatalText?: string;
53
+ /** How a disagreement is told to the model when its record goes back (default `disagreementText`). */
54
+ say?: (disagreement: Disagreement) => string;
55
+ };
56
+ /** The `submit` tool. */
57
+ export declare function recordTool(options: RecordToolOptions): ToolRegistration;
58
+ export type RecordNudgeOptions = {
59
+ /** What is owed, as the nudge names it; undefined for a conversation this hook does not nudge (it owes no record). */
60
+ label(api: Pick<HookApi, "conversationId" | "snapshot">, context: Context): string | undefined | Promise<string | undefined>;
61
+ /** Counts one delivery attempt on the conversation's record, durably, before the model is nudged. A hook cannot
62
+ * commit, so the conversation's owner supplies the write. */
63
+ spend(conversationId: ConversationId, context: Context): Promise<void>;
64
+ /** Delivery attempts the conversation gets (default `DELIVERY_ATTEMPTS`). */
65
+ maxAttempts?: number;
66
+ };
67
+ /** The nudge: a yield with the record still owed continues the run with a reminder, each nudge one delivery attempt;
68
+ * past the attempts the run ends. The nudge that spends the last attempt still continues, so the model is asked once
69
+ * more after it. */
70
+ export declare function recordNudge(options: RecordNudgeOptions): HookRegistration;
71
+ /** One nudge's delivery attempt, as a commit on the conversation's record. */
72
+ export declare const spendNudge: (tx: {
73
+ doc(token: typeof RecordDoc, conversationId: ConversationId): Promise<{
74
+ attempts: number;
75
+ }>;
76
+ }, conversationId: ConversationId) => Promise<void>;