@parcha/agentrun-pi 0.1.0-beta.12 → 0.1.0-beta.14
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 +20 -8
- package/dist/cli.js +10 -1
- package/dist/demo-cli.d.ts +2 -0
- package/dist/demo-cli.js +25 -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 +19 -0
- package/dist/durable/index.js +13 -0
- package/dist/durable/node-runner.d.ts +100 -0
- package/dist/durable/node-runner.js +243 -0
- package/dist/durable/node.d.ts +88 -0
- package/dist/durable/node.js +81 -0
- package/dist/durable/pi-versions.d.ts +34 -0
- package/dist/durable/pi-versions.js +66 -0
- package/dist/durable/readers.d.ts +25 -0
- package/dist/durable/readers.js +64 -0
- package/dist/durable/receipts.d.ts +52 -0
- package/dist/durable/receipts.js +91 -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 +192 -0
- package/dist/durable/task.d.ts +129 -0
- package/dist/durable/task.js +395 -0
- package/dist/extension-service.js +14 -1
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +98 -13
- package/dist/host-session.js +3 -1
- 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 +2 -12
- package/dist/sop.d.ts +12 -0
- package/dist/sop.js +29 -0
- package/dist/tool-context.d.ts +21 -0
- package/dist/tool-context.js +13 -0
- package/dist/workflow-session.d.ts +4 -0
- package/dist/workflow-session.js +3 -2
- package/package.json +43 -8
|
@@ -0,0 +1,52 @@
|
|
|
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 this wrapper runs in: it never answers a call from a receipt its own session wrote. */
|
|
29
|
+
sessionId: string;
|
|
30
|
+
/** Whether a repeat of another session's paid call is answered from its receipt (a continuation's sessions do). Receipts
|
|
31
|
+
* are recorded either way. */
|
|
32
|
+
answer: boolean;
|
|
33
|
+
/** Effects whose outcome is unknown, from the handoff (`handoff.unknown`): a call equal to one is refused. */
|
|
34
|
+
unknown?: readonly Pick<InheritedUnknown, "name" | "argsHash" | "intent">[];
|
|
35
|
+
/** How a call's key is derived. Default: the gateway tool a `fetch` wrapper names, else the tool's own name. */
|
|
36
|
+
keyOf?: (toolName: string, args: unknown) => {
|
|
37
|
+
tool: string;
|
|
38
|
+
argsHash: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
export type ReceiptReuse = {
|
|
42
|
+
/** The tool with its `execute` wrapped; a tool not named in `tools` is returned as it is. A wrapped call whose result
|
|
43
|
+
* carries its blocks only under the key the loaded pi-durable does not read throws `ToolResultKeyError`, and no
|
|
44
|
+
* receipt is written: the model would have received an empty result. */
|
|
45
|
+
wrap<T extends ReusableTool>(tool: T): T;
|
|
46
|
+
/** Calls answered from a receipt and calls refused as unknown, so far. */
|
|
47
|
+
readonly stats: {
|
|
48
|
+
reused: number;
|
|
49
|
+
refused: number;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
export declare function reuseReceipts(options: ReuseReceiptsOptions): ReceiptReuse;
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
if (options.answer) {
|
|
66
|
+
const receipt = await api.snapshot(ReceiptIndex, key, context);
|
|
67
|
+
if (receipt?.result != null && receipt.session !== options.sessionId) {
|
|
68
|
+
stats.reused += 1;
|
|
69
|
+
const text = typeof receipt.result === "string" ? receipt.result : JSON.stringify(receipt.result);
|
|
70
|
+
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 } });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const result = await tool.execute(args, api, context);
|
|
74
|
+
const blocks = resultBlocks(tool.name, result);
|
|
75
|
+
if (!failed(result, blocks)) {
|
|
76
|
+
const value = textOf(blocks ?? []);
|
|
77
|
+
await api.commit(async (tx) => {
|
|
78
|
+
const receipt = await tx.doc(ReceiptIndex, key, null);
|
|
79
|
+
if (receipt.result === null) {
|
|
80
|
+
receipt.tool = intent.tool;
|
|
81
|
+
receipt.argsHash = intent.argsHash;
|
|
82
|
+
receipt.result = value;
|
|
83
|
+
receipt.session = options.sessionId;
|
|
84
|
+
}
|
|
85
|
+
}, context);
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** The record a node delivered, or null while it has not, and the delivery attempts it has spent. */
|
|
2
|
+
export type RecordState = {
|
|
3
|
+
record: unknown;
|
|
4
|
+
attempts: number;
|
|
5
|
+
};
|
|
6
|
+
/** What the second reading disagrees with in a record: its question, the kind of reading, the verdict and why. */
|
|
7
|
+
export type Disagreement = {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: string;
|
|
10
|
+
verdict: string;
|
|
11
|
+
reasons: string[];
|
|
12
|
+
};
|
|
13
|
+
/** Whether the second reading's round was spent, and what it still disagreed with in the delivered record. */
|
|
14
|
+
export type GateState = {
|
|
15
|
+
bounced: boolean;
|
|
16
|
+
disagreements: Disagreement[];
|
|
17
|
+
};
|
|
18
|
+
/** Delivery attempts a node gets: rejections, the second reading's one bounce and nudges, each once. */
|
|
19
|
+
export declare const DELIVERY_ATTEMPTS = 6;
|
|
20
|
+
/** The most schema violations one rejected delivery lists; the submit tool's description tells the model the number. */
|
|
21
|
+
export declare const VIOLATIONS_SHOWN = 8;
|
|
22
|
+
/** One reviewer's reading of a structurally valid record. `no_verdict` is a reviewer that could not answer. */
|
|
23
|
+
export type Reading = "disagrees" | "agrees" | "no_verdict";
|
|
24
|
+
/** The second reading on one structurally valid submission: it sends the record back only while its round
|
|
25
|
+
* is unspent and a reviewer disagrees. A reviewer with no verdict never sends a record back. */
|
|
26
|
+
export declare function gate(bounced: boolean, readings: readonly Reading[]): "accept" | "bounce";
|
|
27
|
+
/** What a node is answered with. Only `delivered` is a delivery; each of the others spends one attempt. */
|
|
28
|
+
export type DeliveryEvent = "rejected" | "bounce" | "nudge" | "delivered";
|
|
29
|
+
/** A node's delivery, as far as the attempts count it. */
|
|
30
|
+
export type DeliveryCount = {
|
|
31
|
+
attempts: number;
|
|
32
|
+
delivered: boolean;
|
|
33
|
+
};
|
|
34
|
+
/** Whether a node's delivery has ended: it delivered, or its attempts are spent. */
|
|
35
|
+
export declare const deliveryEnded: (max: number, state: DeliveryCount) => boolean;
|
|
36
|
+
/** One answer applied to the count. An ended delivery takes no further answer; every other answer counts once. */
|
|
37
|
+
export declare function spend(max: number, state: DeliveryCount, event: DeliveryEvent): DeliveryCount;
|
|
38
|
+
/** What makes a schema one no `submit` call can satisfy: a call's arguments are an object, so the schema must be a
|
|
39
|
+
* JSON Schema object whose root type, when it names one, admits an object. A node runner refuses such a schema
|
|
40
|
+
* before any conversation exists. */
|
|
41
|
+
export declare function lintRecordRoot(schema: unknown): string[];
|
|
42
|
+
/** What an author should fix in a record schema, for a host or a validator to report: the root findings above, and
|
|
43
|
+
* a schema that declares its fields and requires one that is not among them, which the tool's shown fields never
|
|
44
|
+
* name. A record of the second kind can still be delivered (the tool's parameters are open, and the delivery
|
|
45
|
+
* contract that ends the node's task carries the schema with its `required`), so a runner does not refuse it. A
|
|
46
|
+
* schema that declares no `properties` is not held to it. */
|
|
47
|
+
export declare function lintRecordSchema(schema: unknown): string[];
|
|
48
|
+
/** Where the schema expects an object or an array and the submitted value is a string that parses to one, the
|
|
49
|
+
* parsed value; where it expects a number, an integer, a boolean or null and admits no string, the scalar a
|
|
50
|
+
* string spells. Some model families serialise nested tool arguments as strings: the content is valid and
|
|
51
|
+
* every submit fails on its spelling. A string that parses to a container the field also admits is read as that
|
|
52
|
+
* container; only against a scalar does a field that admits a string keep the string. */
|
|
53
|
+
export declare function parseStringifiedContainers(value: unknown, schema: Record<string, unknown> | undefined, depth?: number, rootDefinitions?: Record<string, unknown>): unknown;
|
|
54
|
+
/** The path of every string that spells "no value" in a field whose schema admits JSON null. */
|
|
55
|
+
export declare function nullSpellings(value: unknown, schema: unknown, root?: unknown, at?: string): string[];
|
|
56
|
+
/** The record with every such string replaced by JSON null, in place. A repair the record as a whole refuses
|
|
57
|
+
* (it satisfied its schema before and does not after) is undone in full, so a spelling stays only where null
|
|
58
|
+
* is inadmissible; `undo: false` leaves that judgement to a caller that repairs more of the record and judges the
|
|
59
|
+
* whole. `""` is a value and stays. */
|
|
60
|
+
export declare function healNullSpellings<T>(record: T, schema: Record<string, unknown>, options?: {
|
|
61
|
+
undo?: boolean;
|
|
62
|
+
}): T;
|
|
63
|
+
/** A record's transport spellings repaired against its schema, without touching `args`. */
|
|
64
|
+
export declare function repairRecord(args: unknown, schema: Record<string, unknown>): unknown;
|
|
65
|
+
/** The file a submission names and nothing else: `{ <key>: "relative/path.json" }`. */
|
|
66
|
+
export declare function fileSubmission(args: unknown, key: string): string | null;
|
|
67
|
+
/** A submission with fields beside the file key. Fields that form a whole record on their own are that record;
|
|
68
|
+
* otherwise the file holds the record, and each field beside it may only restate the file's value. A schema
|
|
69
|
+
* that declares the file key as its own field keeps it inline. */
|
|
70
|
+
export declare function mixedFileSubmission(args: unknown, schema: Record<string, unknown> | undefined, key: string): {
|
|
71
|
+
file: string;
|
|
72
|
+
extras: Record<string, unknown>;
|
|
73
|
+
} | null;
|
|
74
|
+
/** The fields beside the file key that the file's record does not hold with the same value. */
|
|
75
|
+
export declare function extrasNotInRecord(record: unknown, extras: Record<string, unknown>): string[];
|
|
76
|
+
/** One host check's refusal of a candidate: the problems the model must fix, and the reason they are grouped under. */
|
|
77
|
+
export type Refusal = {
|
|
78
|
+
problems: string[];
|
|
79
|
+
reason?: string;
|
|
80
|
+
};
|
|
81
|
+
/** A second reader of a structurally valid record. It returns what it disagrees with (nothing when it agrees or
|
|
82
|
+
* has no verdict). `round` says whether a disagreement still sends the record back. A reviewer that throws
|
|
83
|
+
* decides nothing, unless the contract's `fatal` claims the error. */
|
|
84
|
+
export type Reviewer = (candidate: unknown, context: {
|
|
85
|
+
round: boolean;
|
|
86
|
+
}) => Disagreement[] | Promise<Disagreement[]>;
|
|
87
|
+
export type RecordContract = {
|
|
88
|
+
/** The node's record schema, the `submit` tool's contract. */
|
|
89
|
+
schema: Record<string, unknown>;
|
|
90
|
+
/** Delivery attempts the node gets (default `DELIVERY_ATTEMPTS`). */
|
|
91
|
+
maxAttempts?: number;
|
|
92
|
+
/** A record may be delivered as a workspace file named under `key`; `read` returns the file's parsed JSON or
|
|
93
|
+
* throws (an error whose `code` is `ENOENT` for a file that is not there). Absent, a record is inline only. */
|
|
94
|
+
file?: {
|
|
95
|
+
key: string;
|
|
96
|
+
read(path: string): Promise<unknown>;
|
|
97
|
+
};
|
|
98
|
+
/** Contracts the host owns, held before the schema: each problem is said with the core's own findings. */
|
|
99
|
+
contracts?: (candidate: unknown) => string[] | Promise<string[]>;
|
|
100
|
+
/** Checks held after the schema, in order; the first refusal is the rejection. One that throws refuses with
|
|
101
|
+
* what it threw. */
|
|
102
|
+
checks?: ReadonlyArray<(candidate: unknown) => Refusal | null | Promise<Refusal | null>>;
|
|
103
|
+
/** The second reading, in order. */
|
|
104
|
+
reviewers?: readonly Reviewer[];
|
|
105
|
+
/** Whether a reviewer's error is the caller's own failure, to be thrown and not absorbed. */
|
|
106
|
+
fatal?: (error: unknown) => boolean;
|
|
107
|
+
};
|
|
108
|
+
/** The two states behind a node's delivery. Each write is one atomic change; `reject` and `bounce` return the
|
|
109
|
+
* attempts spent after it. The bounce spends the round before the model is told, so a crash cannot grant it twice. */
|
|
110
|
+
export type RecordStore = {
|
|
111
|
+
read(): Promise<{
|
|
112
|
+
record: RecordState;
|
|
113
|
+
gate: GateState;
|
|
114
|
+
}>;
|
|
115
|
+
reject(): Promise<number>;
|
|
116
|
+
bounce(): Promise<number>;
|
|
117
|
+
accept(record: unknown, disagreements: Disagreement[]): Promise<void>;
|
|
118
|
+
};
|
|
119
|
+
/** What one submission came to. `spent` says the delivery attempts are gone and the node's run ends here. */
|
|
120
|
+
export type Delivery = {
|
|
121
|
+
status: "already";
|
|
122
|
+
} | {
|
|
123
|
+
status: "rejected";
|
|
124
|
+
attempts: number;
|
|
125
|
+
spent: boolean;
|
|
126
|
+
reason: string;
|
|
127
|
+
problems: string[];
|
|
128
|
+
} | {
|
|
129
|
+
status: "bounced";
|
|
130
|
+
attempts: number;
|
|
131
|
+
spent: boolean;
|
|
132
|
+
disagreements: Disagreement[];
|
|
133
|
+
} | {
|
|
134
|
+
status: "accepted";
|
|
135
|
+
record: unknown;
|
|
136
|
+
disagreements: Disagreement[];
|
|
137
|
+
};
|
|
138
|
+
/** Decide one `submit` call. `args` are the call's arguments as the model sent them, or as `repairRecord`
|
|
139
|
+
* already repaired them. */
|
|
140
|
+
export declare function deliver(contract: RecordContract, store: RecordStore, args: unknown): Promise<Delivery>;
|
|
141
|
+
/** The `submit` tool's answer to the model for one delivery. */
|
|
142
|
+
export declare function deliveryText(delivery: Delivery, maxAttempts?: number, say?: (disagreement: Disagreement) => string): string;
|
|
143
|
+
/** One disagreement as the model reads it. */
|
|
144
|
+
export declare const disagreementText: (d: Disagreement) => string;
|
|
145
|
+
/** Whether a delivery ends the node's run: a record was delivered, or the attempts are spent. */
|
|
146
|
+
export declare const deliveryTerminates: (delivery: Delivery) => boolean;
|
|
147
|
+
/** The delivery contract a node's task ends with: the label, the file envelope when one is offered, the
|
|
148
|
+
* authoritative schema and the host's own delivery instructions. The model sees the exact schema only here. */
|
|
149
|
+
export declare function submitFooter(record: {
|
|
150
|
+
label: string;
|
|
151
|
+
schema: Record<string, unknown>;
|
|
152
|
+
fileKey?: string;
|
|
153
|
+
instructions?: string;
|
|
154
|
+
reviewed?: boolean;
|
|
155
|
+
}): string;
|
|
156
|
+
/** What a yield without a record is answered with, when the delivery has an attempt left for it. */
|
|
157
|
+
export declare const nudgeText: (label: string) => string;
|