@hachej/boring-agent 0.4.26 → 0.4.28

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.
@@ -0,0 +1,194 @@
1
+ import { settleResult, validateResult, validationError } from './run-result.js';
2
+ import { OVER_BUDGET, deliverRunEnd, endsWithError, isOverBudget } from './runs.js';
3
+ /**
4
+ * The runs a home keeps in a person's own store, and the runner over them: start a run by its key (the same key is the same run),
5
+ * end it exactly once, deliver its end to the operation that started it, cancel it, and find again what a stopped process left.
6
+ * The store is the host's data (a table in its SQLite file); the model turns stay Pi's (the run is a native conversation).
7
+ */
8
+ export const RUNS_TABLE = 'CREATE TABLE IF NOT EXISTS _host_agent_runs (key TEXT PRIMARY KEY, agent TEXT NOT NULL, status TEXT NOT NULL, output TEXT, error TEXT, deliver TEXT, validation TEXT, delivered INTEGER NOT NULL DEFAULT 0, person TEXT NOT NULL, usage TEXT, started_at TEXT)';
9
+ /** Columns added after the table first shipped: a store created before them gets them here. */
10
+ const LATE_COLUMNS = ['validation TEXT', 'usage TEXT', 'started_at TEXT'];
11
+ /** A refusal with an HTTP-like `status` (404: no such agent; 409: the key belongs to another agent). */
12
+ export class RunRefused extends Error {
13
+ status;
14
+ constructor(status, message) {
15
+ super(message);
16
+ this.status = status;
17
+ this.name = 'RunRefused';
18
+ }
19
+ }
20
+ /** The ledger over a store's synchronous SQL (`SyncSql`: `UPDATE ... RETURNING` is what makes an end happen once). */
21
+ export function createRunLedger(sql) {
22
+ const rowsOf = (text, params = []) => sql(text, params);
23
+ let ready = false;
24
+ const ensure = () => {
25
+ if (ready)
26
+ return;
27
+ sql(RUNS_TABLE, []);
28
+ const have = new Set(sql('PRAGMA table_info(_host_agent_runs)', []).map(c => String(c.name)));
29
+ for (const column of LATE_COLUMNS)
30
+ if (!have.has(column.split(' ')[0]))
31
+ sql(`ALTER TABLE _host_agent_runs ADD COLUMN ${column}`, []);
32
+ ready = true;
33
+ };
34
+ const get = (key) => { ensure(); return rowsOf('SELECT * FROM _host_agent_runs WHERE key = ?', [key])[0]; };
35
+ return {
36
+ ensure, get,
37
+ /** Record a new running run. The caller has checked `get(key)` first. */
38
+ begin(run) {
39
+ ensure();
40
+ sql("INSERT INTO _host_agent_runs (key, agent, status, deliver, person, started_at) VALUES (?, ?, 'running', ?, ?, ?)", [run.key, run.agent, run.deliver, JSON.stringify(run.person), run.startedAt]);
41
+ },
42
+ /** End a run still running, once (a cancel and the run's own end race): false when something else ended it first. */
43
+ end(key, status, options = {}) {
44
+ ensure();
45
+ const changed = rowsOf("UPDATE _host_agent_runs SET status = ?, output = ?, error = ?, validation = ?, usage = ? WHERE key = ? AND status = 'running' RETURNING key", [status, options.output === undefined ? null : JSON.stringify(options.output), options.error ?? null, options.validation ? JSON.stringify(options.validation) : null, options.usage ? JSON.stringify(options.usage) : null, key]);
46
+ return changed.length > 0;
47
+ },
48
+ /** A completed run whose delivery-time check failed: its end becomes the failure (never after it was delivered). */
49
+ fail(key, status, error) {
50
+ ensure();
51
+ sql('UPDATE _host_agent_runs SET status = ?, output = NULL, error = ?, validation = NULL WHERE key = ? AND delivered = 0', [status, error, key]);
52
+ },
53
+ markDelivered(key) { ensure(); sql('UPDATE _host_agent_runs SET delivered = 1 WHERE key = ?', [key]); },
54
+ /** Runs a stopped process left: still running (they end `interrupted`), or ended and not delivered. */
55
+ left() { ensure(); return rowsOf("SELECT * FROM _host_agent_runs WHERE status = 'running' OR (deliver IS NOT NULL AND delivered = 0)"); },
56
+ view(row) {
57
+ return {
58
+ run: row.key, status: row.status, ...(row.started_at ? { startedAt: row.started_at } : {}),
59
+ ...(row.status === 'completed' && row.output !== null ? { output: JSON.parse(row.output) } : {}),
60
+ usage: row.usage ? JSON.parse(row.usage) : null,
61
+ ...(endsWithError(row.status) ? { error: row.error ?? row.status } : {}),
62
+ };
63
+ },
64
+ };
65
+ }
66
+ /**
67
+ * The runner of one person's store. A run lives in this process: one a restart cut short is found by `recover` and ends
68
+ * `interrupted`; one that ended and was not delivered is delivered then. Delivery is once (the host's receipt `deliver:<key>`).
69
+ */
70
+ export function createRunner(options) {
71
+ const { ledger, person, context, has, prepare, invoke, admit, now = () => new Date().toISOString() } = options;
72
+ const live = new Map();
73
+ const delivering = new Map();
74
+ const overBudget = () => { if (!admit)
75
+ return false; try {
76
+ admit();
77
+ return false;
78
+ }
79
+ catch (error) {
80
+ return isOverBudget(error);
81
+ } };
82
+ const deliver = async (row) => {
83
+ const active = delivering.get(row.key);
84
+ if (active)
85
+ return active;
86
+ const pending = deliverPending(ledger.get(row.key));
87
+ delivering.set(row.key, pending);
88
+ try {
89
+ await pending;
90
+ }
91
+ finally {
92
+ delivering.delete(row.key);
93
+ }
94
+ };
95
+ const deliverPending = async (row) => {
96
+ if (!row.deliver || row.delivered)
97
+ return;
98
+ const view = ledger.view(row);
99
+ const checked = row.status === 'completed' && row.validation ? JSON.parse(row.validation) : undefined;
100
+ const sent = await deliverRunEnd({
101
+ operation: row.deliver, key: row.key, invoke,
102
+ end: { status: row.status, ...(view.output !== undefined ? { output: view.output } : {}), usage: view.usage, ...(view.error ? { error: view.error } : {}) },
103
+ ...(checked ? { recheck: async () => {
104
+ const result = await validateResult(checked.card, view.output, checked.evidence, invoke, 'delivery', `result-delivery:${row.key}:${crypto.randomUUID()}`);
105
+ return result.valid ? undefined : { end: 'invalid_output', error: result.hint ?? 'Return a supported result.' };
106
+ }, onFailure: (failure) => ledger.fail(row.key, failure.end, failure.error) } : {}),
107
+ ...(options.deliverAttempts !== undefined ? { attempts: options.deliverAttempts } : {}), ...(options.deliverBackoffMs !== undefined ? { backoffMs: options.deliverBackoffMs } : {}),
108
+ });
109
+ if (sent.delivered)
110
+ ledger.markDelivered(row.key);
111
+ };
112
+ /** Ends a run still running, once, then delivers it; false when something else ended it first. */
113
+ const end = async (key, status, endOptions = {}, beforeDeliver) => {
114
+ if (!ledger.end(key, status, endOptions))
115
+ return false;
116
+ await beforeDeliver?.();
117
+ await deliver({ key });
118
+ return true;
119
+ };
120
+ const answerOf = async (execution) => settleResult({ card: execution.card, conversation: execution.conversation, submission: execution.submission, invoke, context });
121
+ /**
122
+ * Runs left over by an earlier process. A run whose delivery is in flight is skipped, never awaited: a status read made from
123
+ * inside that delivery (its operation reading the run) would otherwise wait on itself.
124
+ */
125
+ const recover = async () => {
126
+ for (const row of ledger.left()) {
127
+ if (delivering.has(row.key))
128
+ continue;
129
+ if (row.status === 'running' && !live.has(row.key))
130
+ await end(row.key, 'interrupted', { error: 'the process stopped before the run ended' });
131
+ else if (row.status !== 'running')
132
+ await deliver(row);
133
+ }
134
+ };
135
+ return {
136
+ /** Starts the run of this key (a key already started is the same run, whatever else is asked): launched and returned at once. */
137
+ async start(agent, input, key, deliverTo) {
138
+ if (!(await has(agent)))
139
+ throw new RunRefused(404, `no agent ${agent}`);
140
+ const before = ledger.get(key);
141
+ if (before) {
142
+ if (before.agent !== agent)
143
+ throw new RunRefused(409, `key ${key} is a run of ${before.agent}`);
144
+ return ledger.view(before);
145
+ }
146
+ ledger.begin({ key, agent, deliver: deliverTo, person, startedAt: now() });
147
+ // Over the host's spend cap: the run is never started, and ends (and is delivered) as `error: over_budget`.
148
+ const refused = overBudget();
149
+ const execution = refused ? Promise.resolve(null) : Promise.resolve().then(() => prepare(agent, input));
150
+ live.set(key, execution);
151
+ void (async () => {
152
+ try {
153
+ const ready = await execution;
154
+ if (refused) {
155
+ await end(key, 'error', { error: OVER_BUDGET });
156
+ return;
157
+ }
158
+ const answer = ready ? await answerOf(ready) : null;
159
+ const usage = ready ? await Promise.resolve(options.usage?.(ready.conversation) ?? null).catch(() => null) : null;
160
+ // A run cut short by the cap (a later turn refused) ends with the cap's code, not the model's error text.
161
+ if (!answer || answer.error !== undefined)
162
+ await end(key, (answer?.end ?? 'error'), { error: overBudget() ? OVER_BUDGET : answer?.error ?? `${agent} did not answer`, usage });
163
+ else {
164
+ const result = answer.result;
165
+ await end(key, 'completed', { output: result === undefined ? undefined : 'output' in result ? result.output : result.text, ...(answer.validation ? { validation: answer.validation } : {}), usage });
166
+ }
167
+ }
168
+ catch (error) {
169
+ const failure = validationError(error);
170
+ await end(key, failure.end, { error: overBudget() ? OVER_BUDGET : failure.error }).catch(() => { });
171
+ }
172
+ finally {
173
+ live.delete(key);
174
+ }
175
+ })();
176
+ return ledger.view(ledger.get(key));
177
+ },
178
+ /** The run of this key as recorded, or null. Never waits on a delivery in flight: safe to call from inside one. */
179
+ async status(key) {
180
+ await recover();
181
+ const row = ledger.get(key);
182
+ return row ? ledger.view(row) : null;
183
+ },
184
+ /** Cancels it (its delivery gets `cancelled`); a run that has ended stays as it is; null: no such run. */
185
+ async cancel(key) {
186
+ if (!ledger.get(key))
187
+ return null;
188
+ // A run whose preparation failed has nothing to abort; the cancelled end is delivered either way.
189
+ await end(key, 'cancelled', {}, async () => { await (await live.get(key)?.catch(() => null))?.conversation.abort(context).catch(() => { }); });
190
+ return ledger.view(ledger.get(key));
191
+ },
192
+ recover,
193
+ };
194
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The outbox of runs an app operation asks for. The operation stages its starts in the SAME transaction as its writes and its
3
+ * receipt, in the store it touches (the app database and the agent home are two stores: this table is the only glue). After the
4
+ * commit the host launches each row with its key and removes it once the run is acknowledged; a row left behind (a crash, a
5
+ * refused call) is launched again later, which the key makes harmless. Runtime-neutral: `SyncSql` is the store's synchronous SQL
6
+ * (a Durable Object's `sql.exec`, `node:sqlite`); this file opens no transaction and no connection.
7
+ */
8
+ /** The store's synchronous SQL: rows as plain objects (`SELECT`), or `[]` (a write). */
9
+ export type SyncSql = (sql: string, params: unknown[]) => Record<string, unknown>[];
10
+ export declare const EFFORTS: readonly ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
11
+ export type Effort = typeof EFFORTS[number];
12
+ export declare const MODEL_ID: RegExp;
13
+ export declare const START_KEY: RegExp;
14
+ export declare const AGENT_NAME: RegExp;
15
+ export declare const OPERATION_NAME: RegExp;
16
+ export declare const MAX_START_INPUT = 100000;
17
+ export declare const MAX_STARTS = 20;
18
+ export declare const OUTBOX_TABLE = "CREATE TABLE IF NOT EXISTS _host_agent_outbox (actor TEXT NOT NULL, key TEXT NOT NULL, agent TEXT NOT NULL, input TEXT NOT NULL, deliver TEXT, person TEXT NOT NULL, at TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(actor, key))";
19
+ /** A run an operation asks for: `input` is any JSON, `deliver` the operation that receives the run's end, `model` and `effort` this run's own. */
20
+ export interface Start {
21
+ readonly key: string;
22
+ readonly agent: string;
23
+ readonly input?: unknown;
24
+ readonly deliver?: string | null;
25
+ readonly model?: string | null;
26
+ readonly effort?: Effort | null;
27
+ }
28
+ /** A start as stored: `input` is JSON text. */
29
+ export interface CheckedStart {
30
+ readonly key: string;
31
+ readonly agent: string;
32
+ readonly input: string;
33
+ readonly deliver: string | null;
34
+ readonly model: string | null;
35
+ readonly effort: Effort | null;
36
+ }
37
+ export interface OutboxRow<P = unknown> {
38
+ readonly actor: string;
39
+ readonly key: string;
40
+ readonly agent: string;
41
+ readonly input: unknown;
42
+ readonly deliver: string | null;
43
+ readonly person: P;
44
+ readonly at: string;
45
+ readonly attempts: number;
46
+ readonly next_at: number;
47
+ readonly model?: string;
48
+ readonly effort?: Effort;
49
+ }
50
+ type Awaitable<T> = T | Promise<T>;
51
+ /** The outbox of one store as its owner reads it. A remote store (a Durable Object other than the caller) offers the same calls over RPC. */
52
+ export interface Outbox<P = unknown> {
53
+ /** The rows due at `now`, oldest first. */
54
+ due(now: number, limit?: number): Awaitable<OutboxRow<P>[]>;
55
+ pending(actor: string, key: string): Awaitable<OutboxRow<P> | null>;
56
+ /** The earliest due time, or null when the outbox is empty. */
57
+ next(): Awaitable<number | null>;
58
+ /** The run exists: drop the row. */
59
+ ack(actor: string, key: string): Awaitable<null>;
60
+ /** A launch that may work later: back off (1 s doubling, 5 min at most). */
61
+ retry(row: OutboxRow<P>, now: number): Awaitable<null>;
62
+ }
63
+ export type Launched = {
64
+ readonly ok: true;
65
+ } | {
66
+ readonly ok: false;
67
+ readonly retry?: boolean;
68
+ readonly status?: number;
69
+ readonly error?: string;
70
+ };
71
+ /** The runs an operation asked for, checked once more by the host: bounded, well named, and a key never used twice for different runs. Throws a `{ status: 400 }` error. */
72
+ export declare function checkStarts(starts: unknown): CheckedStart[];
73
+ /**
74
+ * Stage rows inside the caller's transaction (its own BEGIN/COMMIT or `transactionSync`: this function never opens one). Keys are
75
+ * scoped by actor, and the store is already one app's one scope: Alice and Bob may use the same key. A key staged before and not
76
+ * yet launched keeps its first row.
77
+ */
78
+ export declare function stageStarts(run: SyncSql, options: {
79
+ actor: string;
80
+ person: unknown;
81
+ starts: unknown;
82
+ at: string;
83
+ }): void;
84
+ /** The outbox of one store (`SyncSql` synchronous): see `Outbox`. */
85
+ export declare function outboxOver<P = unknown>(run: SyncSql): Outbox<P>;
86
+ /**
87
+ * Launch the rows that are due (or only `only` = { actor, key }, due or not) through `outbox`. `launch(row)` starts the run and
88
+ * answers `{ ok: true }` when it exists (a replay of the key counts), `{ ok: false, retry: true }` for a refusal that may pass
89
+ * (service down, overloaded), `{ ok: false }` for one that never will (the key belongs to another agent): that row is dropped and
90
+ * `onDrop(row, answer)` told. `cutoff` selects rows independently of `now`, the retry clock (a start-up can ignore old backoff).
91
+ * One failing row never stops the others; a row is removed only after the run was acknowledged.
92
+ */
93
+ export declare function drainStarts<P = unknown>(outbox: Outbox<P>, options: {
94
+ now: number;
95
+ cutoff?: number;
96
+ launch: (row: OutboxRow<P>) => Promise<Launched>;
97
+ only?: {
98
+ actor: string;
99
+ key: string;
100
+ } | null;
101
+ onDrop?: (row: OutboxRow<P>, answer: Launched) => void;
102
+ }): Promise<{
103
+ launched: string[];
104
+ retried: string[];
105
+ dropped: string[];
106
+ }>;
107
+ export {};
108
+ //# sourceMappingURL=run-outbox.d.ts.map
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The outbox of runs an app operation asks for. The operation stages its starts in the SAME transaction as its writes and its
3
+ * receipt, in the store it touches (the app database and the agent home are two stores: this table is the only glue). After the
4
+ * commit the host launches each row with its key and removes it once the run is acknowledged; a row left behind (a crash, a
5
+ * refused call) is launched again later, which the key makes harmless. Runtime-neutral: `SyncSql` is the store's synchronous SQL
6
+ * (a Durable Object's `sql.exec`, `node:sqlite`); this file opens no transaction and no connection.
7
+ */
8
+ export const EFFORTS = Object.freeze(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
9
+ export const MODEL_ID = /^[A-Za-z0-9@:/._-]{1,120}$/;
10
+ export const START_KEY = /^[\w.:-]{1,100}$/;
11
+ export const AGENT_NAME = /^[a-z][a-z0-9-]{0,40}$/;
12
+ export const OPERATION_NAME = /^[a-z][\w-]*\.[a-z][\w.-]*$/;
13
+ export const MAX_START_INPUT = 100_000;
14
+ export const MAX_STARTS = 20;
15
+ const BACKOFF_FIRST_MS = 1000;
16
+ const BACKOFF_MAX_MS = 5 * 60_000;
17
+ export const OUTBOX_TABLE = 'CREATE TABLE IF NOT EXISTS _host_agent_outbox (actor TEXT NOT NULL, key TEXT NOT NULL, agent TEXT NOT NULL, input TEXT NOT NULL, deliver TEXT, person TEXT NOT NULL, at TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(actor, key))';
18
+ /** `options` (JSON: { model?, effort? }) came later than the table: a store created before it gets the column here. */
19
+ function ready(run) {
20
+ run(OUTBOX_TABLE, []);
21
+ try {
22
+ run('ALTER TABLE _host_agent_outbox ADD COLUMN options TEXT', []);
23
+ }
24
+ catch (error) {
25
+ if (!/duplicate column/i.test(String(error?.message ?? error)))
26
+ throw error;
27
+ }
28
+ }
29
+ /** The runs an operation asked for, checked once more by the host: bounded, well named, and a key never used twice for different runs. Throws a `{ status: 400 }` error. */
30
+ export function checkStarts(starts) {
31
+ const refuse = (message) => { throw Object.assign(new Error(message), { status: 400 }); };
32
+ if (!Array.isArray(starts) || starts.length > MAX_STARTS)
33
+ refuse(`an operation starts at most ${MAX_STARTS} agent runs`);
34
+ const seen = new Map();
35
+ return starts.map((s) => {
36
+ if (typeof s?.key !== 'string' || !START_KEY.test(s.key))
37
+ refuse("agents.start: key is 1 to 100 letters, digits, '.', ':', '_' or '-'");
38
+ if (typeof s.agent !== 'string' || !AGENT_NAME.test(s.agent))
39
+ refuse('agents.start: agent is an agent name');
40
+ if (s.deliver != null && (typeof s.deliver !== 'string' || !OPERATION_NAME.test(s.deliver)))
41
+ refuse('agents.start: deliver is an operation name');
42
+ const input = JSON.stringify(s.input ?? '');
43
+ if (typeof input !== 'string' || input.length > MAX_START_INPUT)
44
+ refuse(`agents.start: input must be JSON, at most ${MAX_START_INPUT} characters`);
45
+ if (s.model != null && (typeof s.model !== 'string' || !MODEL_ID.test(s.model)))
46
+ refuse('agents.start: model is a model id from the approved catalog');
47
+ if (s.effort != null && !EFFORTS.includes(s.effort))
48
+ refuse(`agents.start: effort is one of ${EFFORTS.join(', ')}`);
49
+ const start = { key: s.key, agent: s.agent, input: input, deliver: s.deliver ?? null, model: s.model ?? null, effort: s.effort ?? null };
50
+ const before = seen.get(start.key);
51
+ if (before && (before.agent !== start.agent || before.input !== start.input || before.deliver !== start.deliver || before.model !== start.model || before.effort !== start.effort))
52
+ refuse(`agents.start: key ${start.key} is used twice with different runs`);
53
+ seen.set(start.key, start);
54
+ return start;
55
+ }).filter((s, i, all) => all.findIndex(o => o.key === s.key) === i);
56
+ }
57
+ /**
58
+ * Stage rows inside the caller's transaction (its own BEGIN/COMMIT or `transactionSync`: this function never opens one). Keys are
59
+ * scoped by actor, and the store is already one app's one scope: Alice and Bob may use the same key. A key staged before and not
60
+ * yet launched keeps its first row.
61
+ */
62
+ export function stageStarts(run, options) {
63
+ ready(run);
64
+ const who = JSON.stringify(options.person);
65
+ for (const s of checkStarts(options.starts)) {
66
+ const own = s.model || s.effort ? JSON.stringify({ ...(s.model ? { model: s.model } : {}), ...(s.effort ? { effort: s.effort } : {}) }) : null;
67
+ run('INSERT OR IGNORE INTO _host_agent_outbox (actor, key, agent, input, deliver, person, at, options) VALUES (?,?,?,?,?,?,?,?)', [options.actor, s.key, s.agent, s.input, s.deliver, who, options.at, own]);
68
+ }
69
+ }
70
+ const rowOf = ({ options, ...r }) => ({ ...r, input: JSON.parse(r.input), person: JSON.parse(r.person), ...(options ? JSON.parse(options) : {}) });
71
+ /** The outbox of one store (`SyncSql` synchronous): see `Outbox`. */
72
+ export function outboxOver(run) {
73
+ return {
74
+ due(now, limit = 20) { ready(run); return run('SELECT * FROM _host_agent_outbox WHERE next_at <= ? ORDER BY at, rowid LIMIT ?', [now, limit]).map(r => rowOf(r)); },
75
+ pending(actor, key) { ready(run); const r = run('SELECT * FROM _host_agent_outbox WHERE actor = ? AND key = ?', [actor, key])[0]; return r ? rowOf(r) : null; },
76
+ next() { ready(run); return run('SELECT MIN(next_at) AS at FROM _host_agent_outbox', [])[0]?.at ?? null; },
77
+ ack(actor, key) { ready(run); run('DELETE FROM _host_agent_outbox WHERE actor = ? AND key = ?', [actor, key]); return null; },
78
+ retry(row, now) { ready(run); run('UPDATE _host_agent_outbox SET attempts = attempts + 1, next_at = ? WHERE actor = ? AND key = ?', [now + Math.min(BACKOFF_FIRST_MS * 2 ** Math.min(row.attempts, 20), BACKOFF_MAX_MS), row.actor, row.key]); return null; },
79
+ };
80
+ }
81
+ /**
82
+ * Launch the rows that are due (or only `only` = { actor, key }, due or not) through `outbox`. `launch(row)` starts the run and
83
+ * answers `{ ok: true }` when it exists (a replay of the key counts), `{ ok: false, retry: true }` for a refusal that may pass
84
+ * (service down, overloaded), `{ ok: false }` for one that never will (the key belongs to another agent): that row is dropped and
85
+ * `onDrop(row, answer)` told. `cutoff` selects rows independently of `now`, the retry clock (a start-up can ignore old backoff).
86
+ * One failing row never stops the others; a row is removed only after the run was acknowledged.
87
+ */
88
+ export async function drainStarts(outbox, options) {
89
+ const { now, cutoff = now, launch, only = null, onDrop = () => { } } = options;
90
+ const rows = only ? [await outbox.pending(only.actor, only.key)].filter((r) => r !== null) : await outbox.due(cutoff);
91
+ const out = { launched: [], retried: [], dropped: [] };
92
+ for (const row of rows) {
93
+ let r;
94
+ try {
95
+ r = await launch(row);
96
+ }
97
+ catch (error) {
98
+ r = { ok: false, retry: true, error: String(error?.message ?? error) };
99
+ }
100
+ if (r?.ok) {
101
+ await outbox.ack(row.actor, row.key);
102
+ out.launched.push(row.key);
103
+ }
104
+ else if (r?.retry) {
105
+ await outbox.retry(row, now);
106
+ out.retried.push(row.key);
107
+ }
108
+ else {
109
+ await outbox.ack(row.actor, row.key);
110
+ out.dropped.push(row.key);
111
+ onDrop(row, r);
112
+ }
113
+ }
114
+ return out;
115
+ }
@@ -0,0 +1,201 @@
1
+ import type { ConversationDocToken, ToolExecutionResult, Tx } from '@earendil-works/pi-durable';
2
+ import type { Context } from '@earendil-works/chord';
3
+ /**
4
+ * The result protocol of a one-call run: the agent answers by calling one result tool (or in plain text), the host validates it and
5
+ * spends a repair budget on a wrong answer, in the run's own conversation. The pure half (`resultState`) and the settle loop are the
6
+ * same for every home, so they live here once. Pi owns the turns and the transcript; this reads the host's whole transcript and only
7
+ * submits a native follow-up input to ask for a corrected result.
8
+ */
9
+ /** A message of the host's transcript as the protocol reads it (the pi-ai message, kept structural). */
10
+ export interface ResultMessage {
11
+ readonly role: string;
12
+ readonly content?: unknown;
13
+ readonly toolCallId?: string;
14
+ readonly toolName?: string;
15
+ readonly isError?: boolean;
16
+ readonly details?: unknown;
17
+ }
18
+ export interface ResultCall {
19
+ readonly id: string;
20
+ readonly name: string;
21
+ readonly arguments: unknown;
22
+ }
23
+ /** `result:` of an agent card: the schema of the answer, the host operation that checks it, and how many repairs it may spend. */
24
+ export interface ResultConfig {
25
+ readonly schema?: JsonObjectSchema;
26
+ readonly validate?: string;
27
+ readonly repairs: number;
28
+ }
29
+ export interface JsonObjectSchema {
30
+ readonly type: 'object';
31
+ readonly properties?: Readonly<Record<string, unknown>>;
32
+ readonly [keyword: string]: unknown;
33
+ }
34
+ /** The result tool of a card (`tool.json`). */
35
+ export interface ResultTool {
36
+ readonly name: string;
37
+ readonly description?: string;
38
+ readonly input: JsonObjectSchema;
39
+ }
40
+ /** What the protocol needs of an agent card: its name, the result tool (structured results) and the `result:` settings. */
41
+ export interface ResultCard {
42
+ readonly name: string;
43
+ readonly output?: ResultTool;
44
+ readonly result?: ResultConfig;
45
+ }
46
+ /** The host's call of a result validator (an operation), as the person whose run it is. `key` makes the call once. */
47
+ export type ResultInvoke = (operation: string, input: unknown, key: string) => Promise<unknown>;
48
+ export type ResultValidation = {
49
+ readonly valid: boolean;
50
+ readonly hint?: string;
51
+ };
52
+ /** The tool results a run obtained, as a validator sees them. */
53
+ export interface ResultEvidence {
54
+ readonly name: string;
55
+ readonly arguments: unknown;
56
+ readonly results: {
57
+ readonly content: unknown;
58
+ readonly isError: boolean;
59
+ };
60
+ }
61
+ export type RunAnswer = {
62
+ readonly output: Record<string, unknown>;
63
+ } | {
64
+ readonly text: string;
65
+ };
66
+ /** Why a run did not produce its result: `end` is the run's end status (`invalid_output`, `interrupted`, `revoked` or `error`). */
67
+ export type ResultFailure = {
68
+ readonly error: string;
69
+ readonly end: string;
70
+ };
71
+ export type SettledResult = {
72
+ readonly result?: RunAnswer;
73
+ readonly validation?: ResultValidationRecord;
74
+ readonly error?: undefined;
75
+ readonly end?: undefined;
76
+ } | ResultFailure;
77
+ /** What delivery re-checks: the card and the evidence the result was accepted on. */
78
+ export interface ResultValidationRecord {
79
+ readonly card: ResultCard;
80
+ readonly evidence: readonly ResultEvidence[];
81
+ }
82
+ /**
83
+ * The per-conversation count of repair attempts that left no result call (a model that answered in text instead of the tool).
84
+ * Defined without `history` and `fork`, exactly as the homes that ran this protocol before persisted it: pi-durable refuses a token
85
+ * whose semantics differ from the stored record, so adding them here would break every existing run store.
86
+ */
87
+ export declare const ResultBudget: ConversationDocToken<{
88
+ misses: number;
89
+ }>;
90
+ /** Parse `result:` of a card. `legacy` is the card's `tool.json`, whose input is the default schema. Throws a plain Error naming the agent. */
91
+ export declare function resultConfig(value: unknown, legacy: {
92
+ readonly input?: JsonObjectSchema;
93
+ } | undefined, name: string): ResultConfig;
94
+ export declare const callsOf: (messages: readonly ResultMessage[]) => ResultCall[];
95
+ export interface ResultState {
96
+ /** The last result call a host tool result accepted, or null. */
97
+ readonly accepted: Record<string, unknown> | null;
98
+ readonly error?: string;
99
+ readonly end?: string;
100
+ /** Result calls made so far, plus the misses recorded for answers that made none. */
101
+ readonly attempts: number;
102
+ /** No attempt left and nothing accepted (or more attempts than the budget allows). */
103
+ readonly exhausted: boolean;
104
+ }
105
+ /** The state of a run's result from its transcript. Only a host tool result can accept a call; a later rejected call cannot replace an accepted one. */
106
+ export declare function resultState(card: ResultCard, messages: readonly ResultMessage[], misses?: number): ResultState;
107
+ /** The message of a one-call run: the input as JSON, and how to answer. */
108
+ export declare function runMessage(card: Pick<ResultCard, 'output'>, input: unknown): string;
109
+ /** A run's answer: only the last host-accepted result call, else the last markdown reply. */
110
+ export declare function runResult(card: ResultCard, messages: readonly ResultMessage[] | undefined): RunAnswer | null;
111
+ /** The model sees the complete result schema as the tool's parameters (nested items, descriptions, required), like a production tool. */
112
+ export declare function resultParameters(card: Required<Pick<ResultCard, 'output'>>): JsonObjectSchema;
113
+ export declare const resultDescription: (card: Required<Pick<ResultCard, "output">>) => string;
114
+ /** The conversation of a run, as the protocol needs it: a native `Conversation` of a Harness satisfies it. */
115
+ export interface ResultConversation {
116
+ readonly id: number;
117
+ commit<T>(fn: (tx: Tx) => Promise<T> | T, context: Context): Promise<T>;
118
+ submit(draft: {
119
+ readonly type: 'input';
120
+ readonly content: string;
121
+ readonly requestId?: string;
122
+ }, context: Context): Promise<ResultSubmission>;
123
+ }
124
+ export interface ResultSubmission {
125
+ wait(context: Context): Promise<{
126
+ readonly status: string;
127
+ readonly reason?: string;
128
+ }>;
129
+ }
130
+ /** Read the host's whole transcript, not a compacted model context or model-provided evidence. */
131
+ export declare function resultMessages(reader: Pick<Tx, 'scanEntries'>, conversationId: number): Promise<ResultMessage[]>;
132
+ /** The tool results a run obtained (every call but the result tool's own), with the arguments that actually executed. */
133
+ export declare function evidenceOf(card: ResultCard, messages: readonly ResultMessage[]): ResultEvidence[];
134
+ /**
135
+ * Check a result: the schema first (no coercion), then the card's `result.validate` operation, called through `invoke` with the
136
+ * evidence. `phase` is `attempt` while the agent answers and `delivery` for the check made again just before the run is delivered;
137
+ * `key` must be fresh for a delivery check. Throws when the validator answers something else than `{ valid, hint? }`.
138
+ */
139
+ export declare function validateResult(card: ResultCard, output: unknown, evidence: readonly ResultEvidence[], invoke: ResultInvoke, phase: 'attempt' | 'delivery', key: string): Promise<ResultValidation>;
140
+ /** `end` of a run from an error thrown while validating: a refusal by the host's authority is `revoked`, anything else `error`. */
141
+ export declare function validationError(error: unknown): ResultFailure;
142
+ /** Record the arguments that actually executed (after guards and coercion), never ones invented in the final answer. */
143
+ export declare function withEvidence<T extends {
144
+ execute: (...args: never[]) => Promise<ToolExecutionResult>;
145
+ }>(tool: T): T;
146
+ /**
147
+ * Settle a one-call run in its own conversation: wait for the answer, read the result, check it, and while it is wrong and the card's
148
+ * repair budget lasts, ask for a corrected result with a native follow-up input (`repair:<conversation>:<n>`, so a replay finds the
149
+ * same submission). A result that passes is checked once more with phase `delivery` before it is returned. Not durable by itself:
150
+ * the caller records the end; a process that dies in the middle ends the run `interrupted`.
151
+ */
152
+ export declare function settleResult(options: {
153
+ card: ResultCard;
154
+ conversation: ResultConversation;
155
+ submission: ResultSubmission;
156
+ invoke: ResultInvoke;
157
+ context: Context;
158
+ readResult?: (card: ResultCard, messages: readonly ResultMessage[]) => RunAnswer | null;
159
+ }): Promise<SettledResult>;
160
+ /** What the result tool's `execute` is given by the Harness (`api`): the invocation's conversation, call and task, and its transaction. */
161
+ export interface ResultToolApi {
162
+ readonly conversationId: number;
163
+ readonly callId: string;
164
+ readonly taskId: number;
165
+ commit<T>(fn: (tx: Tx) => Promise<T> | T, context: Context): Promise<T>;
166
+ snapshot(token: typeof ResultBudget, conversationId: number, context: Context): Promise<{
167
+ readonly misses: number;
168
+ } | undefined>;
169
+ }
170
+ /** The `execute` of the result tool: validates the call the model made, spends the repair budget, and ends the turn when the budget is gone. */
171
+ export declare function resultExecutor(card: ResultCard & {
172
+ readonly output: ResultTool;
173
+ readonly result: ResultConfig;
174
+ }, invoke: ResultInvoke): (args: unknown, api: ResultToolApi, context: Context) => Promise<{
175
+ output: {
176
+ type: "text";
177
+ text: string;
178
+ }[];
179
+ isError: boolean;
180
+ details: {
181
+ resultValidation: ResultFailure;
182
+ };
183
+ control: {
184
+ terminate: true;
185
+ };
186
+ } | {
187
+ control?: {
188
+ terminate: true;
189
+ };
190
+ output: {
191
+ type: "text";
192
+ text: string;
193
+ }[];
194
+ isError: boolean;
195
+ details: {
196
+ resultValidation: {
197
+ valid: boolean;
198
+ };
199
+ };
200
+ }>;
201
+ //# sourceMappingURL=run-result.d.ts.map