@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,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>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { defineDoc, defineTool, GenerationTask, hook, } from "@earendil-works/pi-durable";
|
|
2
|
+
import { DELIVERY_ATTEMPTS, deliver, deliveryTerminates, deliveryText, fileSubmission, mixedFileSubmission, nudgeText, repairRecord, VIOLATIONS_SHOWN, } from "./record.js";
|
|
3
|
+
import { toolResult } from "./pi-versions.js";
|
|
4
|
+
/** The record a conversation delivered, or null while it has not, and the delivery attempts it has spent. */
|
|
5
|
+
export const RecordDoc = defineDoc({
|
|
6
|
+
kind: "agentrun.record", version: 1, scope: "conversation", history: "latest", fork: "initial", initial: () => ({ record: null, attempts: 0 }),
|
|
7
|
+
});
|
|
8
|
+
/** The gate's own state beside the record: whether the second reading's round was spent, and what it still
|
|
9
|
+
* disagreed with in the delivered record. */
|
|
10
|
+
export const GateDoc = defineDoc({
|
|
11
|
+
kind: "agentrun.gate", version: 1, scope: "conversation", history: "latest", fork: "initial", initial: () => ({ bounced: false, disagreements: [] }),
|
|
12
|
+
});
|
|
13
|
+
/** Where `prepareArguments` carries a call's arguments to `execute`. A record's own fields are never read as it. */
|
|
14
|
+
const RAW = "_raw";
|
|
15
|
+
/** The core's store on a conversation's two documents, written through a tool call's commits. */
|
|
16
|
+
export function recordStore(api, context) {
|
|
17
|
+
const id = api.conversationId;
|
|
18
|
+
return {
|
|
19
|
+
read: async () => ({
|
|
20
|
+
record: (await api.snapshot(RecordDoc, id, context)) ?? { record: null, attempts: 0 },
|
|
21
|
+
gate: (await api.snapshot(GateDoc, id, context)) ?? { bounced: false, disagreements: [] },
|
|
22
|
+
}),
|
|
23
|
+
reject: () => api.commit(async (tx) => { const state = await tx.doc(RecordDoc, id); state.attempts += 1; return state.attempts; }, context),
|
|
24
|
+
bounce: () => api.commit(async (tx) => {
|
|
25
|
+
(await tx.doc(GateDoc, id)).bounced = true;
|
|
26
|
+
const state = await tx.doc(RecordDoc, id);
|
|
27
|
+
state.attempts += 1;
|
|
28
|
+
return state.attempts;
|
|
29
|
+
}, context),
|
|
30
|
+
accept: (record, disagreements) => api.commit(async (tx) => {
|
|
31
|
+
const state = await tx.doc(RecordDoc, id);
|
|
32
|
+
state.record = record;
|
|
33
|
+
state.attempts += 1;
|
|
34
|
+
if (disagreements.length)
|
|
35
|
+
(await tx.doc(GateDoc, id)).disagreements = disagreements;
|
|
36
|
+
}, context),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The record a conversation delivered, or undefined while it has not, with what its second reading still disagreed with. */
|
|
40
|
+
export async function deliveredRecord(reader, conversationId, context) {
|
|
41
|
+
const state = await reader.snapshot(RecordDoc, conversationId, context);
|
|
42
|
+
if (state?.record == null)
|
|
43
|
+
return undefined;
|
|
44
|
+
return { record: state.record, disagreements: [...((await reader.snapshot(GateDoc, conversationId, context))?.disagreements ?? [])] };
|
|
45
|
+
}
|
|
46
|
+
/** The `submit` tool as a model is shown it for one record: what it delivers, and the record's top-level fields with
|
|
47
|
+
* their plain types and descriptions, none required, since a partial record is an attempt the core counts. A record
|
|
48
|
+
* that may be delivered as a workspace file is offered the envelope under `fileKey`. */
|
|
49
|
+
export function submitDefinition(record) {
|
|
50
|
+
const { schema, label, fileKey } = record;
|
|
51
|
+
const declared = schema.properties && typeof schema.properties === "object" ? schema.properties : {};
|
|
52
|
+
return {
|
|
53
|
+
description: fileKey
|
|
54
|
+
? `Deliver ${label}. Submit it inline, or for a large record write JSON in the workspace and pass {"${fileKey}":"relative/path.json"}. It ends the run. These parameters are the output schema: submit validates the record against it and returns the violations to fix, up to ${VIOLATIONS_SHOWN} per call, so there is no schema file to find and no need to validate the record yourself first.`
|
|
55
|
+
: `Deliver ${label}. Submit it inline as the tool arguments. It ends the run. These parameters are the output schema: submit validates the record against it and returns the violations to fix, up to ${VIOLATIONS_SHOWN} per call.`,
|
|
56
|
+
parameters: { type: "object", additionalProperties: true, properties: {
|
|
57
|
+
...Object.fromEntries(Object.entries(declared).filter(([key]) => key !== RAW).map(([key, property]) => [key, { ...(typeof property?.type === "string" ? { type: property.type } : {}), ...(typeof property?.description === "string" ? { description: property.description } : {}) }])),
|
|
58
|
+
...(fileKey ? { [fileKey]: { type: "string", description: "Workspace-relative path to the complete JSON record" } } : {})
|
|
59
|
+
} },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** A call's arguments as the core decides them: a file submission as it is, an inline record repaired as a transport
|
|
63
|
+
* spelling. The reserved file key beside inline fields is the transport's, never the record's. */
|
|
64
|
+
function carried(args, schema, fileKey) {
|
|
65
|
+
if (fileKey && (fileSubmission(args, fileKey) !== null || mixedFileSubmission(args, schema, fileKey)))
|
|
66
|
+
return args;
|
|
67
|
+
const inline = fileKey && args && typeof args === "object" && !Array.isArray(args) ? (({ [fileKey]: _reserved, ...rest }) => rest)(args) : args;
|
|
68
|
+
return repairRecord(inline, schema);
|
|
69
|
+
}
|
|
70
|
+
/** The `submit` tool. */
|
|
71
|
+
export function recordTool(options) {
|
|
72
|
+
const { record } = options;
|
|
73
|
+
const shown = record ? submitDefinition(record) : { description: "Deliver the record. It ends the run.", parameters: { type: "object", additionalProperties: true } };
|
|
74
|
+
const answer = (delivery, maxAttempts) => toolResult([{ type: "text", text: deliveryText(delivery, maxAttempts, options.say) }], {
|
|
75
|
+
...(delivery.status === "rejected" || delivery.status === "bounced" ? { isError: true } : {}),
|
|
76
|
+
...(deliveryTerminates(delivery) ? { control: { terminate: true } } : {}),
|
|
77
|
+
});
|
|
78
|
+
return defineTool({
|
|
79
|
+
name: "submit",
|
|
80
|
+
description: shown.description,
|
|
81
|
+
parameters: shown.parameters,
|
|
82
|
+
// Every call's arguments are carried to `execute`; one record's are repaired here, before the Harness validates.
|
|
83
|
+
prepareArguments: (args) => ({ [RAW]: record ? carried(args, record.schema, record.fileKey) : args }),
|
|
84
|
+
replay: "safe",
|
|
85
|
+
executionMode: "sequential",
|
|
86
|
+
execute: async (args, api, context) => {
|
|
87
|
+
try {
|
|
88
|
+
const contract = await options.contract(api, context);
|
|
89
|
+
const raw = args[RAW];
|
|
90
|
+
const delivery = await deliver({ ...contract, ...(options.fatal ? { fatal: options.fatal } : {}) }, recordStore(api, context), record ? raw : carried(raw, contract.schema, contract.file?.key));
|
|
91
|
+
await options.onDelivery?.(delivery, api.conversationId);
|
|
92
|
+
return answer(delivery, contract.maxAttempts ?? DELIVERY_ATTEMPTS);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!options.fatal?.(error))
|
|
96
|
+
throw error;
|
|
97
|
+
options.onFatal?.(error, api.conversationId);
|
|
98
|
+
return toolResult([{ type: "text", text: options.fatalText ?? "The run cannot record this submission; the run stops." }], { isError: true, control: { terminate: true } });
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** The nudge: a yield with the record still owed continues the run with a reminder, each nudge one delivery attempt;
|
|
104
|
+
* past the attempts the run ends. The nudge that spends the last attempt still continues, so the model is asked once
|
|
105
|
+
* more after it. */
|
|
106
|
+
export function recordNudge(options) {
|
|
107
|
+
return hook(GenerationTask, {
|
|
108
|
+
onYield: async (_answer, api, context) => {
|
|
109
|
+
const state = await api.snapshot(RecordDoc, api.conversationId, context);
|
|
110
|
+
if (state?.record != null)
|
|
111
|
+
return undefined;
|
|
112
|
+
const label = await options.label(api, context);
|
|
113
|
+
if (label === undefined || (state?.attempts ?? 0) >= (options.maxAttempts ?? DELIVERY_ATTEMPTS))
|
|
114
|
+
return undefined;
|
|
115
|
+
await options.spend(api.conversationId, context);
|
|
116
|
+
return { continue: nudgeText(label) };
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** One nudge's delivery attempt, as a commit on the conversation's record. */
|
|
121
|
+
export const spendNudge = async (tx, conversationId) => {
|
|
122
|
+
(await tx.doc(RecordDoc, conversationId)).attempts += 1;
|
|
123
|
+
};
|