@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.
- package/README.md +42 -1
- package/dist/actions.d.ts +38 -0
- package/dist/actions.js +158 -0
- package/dist/ask-user.js +1 -1
- package/dist/browser-task.js +1 -1
- package/dist/conversations.js +1 -1
- package/dist/file-guard.js +1 -1
- package/dist/memory/optchat.js +5 -5
- package/dist/native-version.d.ts +1 -1
- package/dist/native-version.js +1 -1
- package/dist/questions.js +13 -6
- package/dist/room.js +3 -3
- package/dist/run-ledger.d.ts +98 -0
- package/dist/run-ledger.js +194 -0
- package/dist/run-outbox.d.ts +108 -0
- package/dist/run-outbox.js +115 -0
- package/dist/run-result.d.ts +201 -0
- package/dist/run-result.js +195 -0
- package/dist/runs.d.ts +83 -0
- package/dist/runs.js +83 -0
- package/dist/schedule.d.ts +1 -1
- package/dist/schedule.js +10 -3
- package/dist/view-open.js +1 -1
- package/dist/wait-until.d.ts +12 -0
- package/dist/wait-until.js +20 -0
- package/package.json +27 -6
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { defineDoc } from '@earendil-works/pi-durable';
|
|
2
|
+
import { Check, Errors } from 'typebox/schema';
|
|
3
|
+
const MAX_HINT = 1000;
|
|
4
|
+
const OPERATION = /^[A-Za-z][\w.-]{0,80}$/;
|
|
5
|
+
/** A settled submission that was cut short (aborted, reset, superseded) is `interrupted`; any other non-answer is an `error`. */
|
|
6
|
+
const INTERRUPTED = new Set(['aborted', 'reset', 'stale']);
|
|
7
|
+
/**
|
|
8
|
+
* The per-conversation count of repair attempts that left no result call (a model that answered in text instead of the tool).
|
|
9
|
+
* Defined without `history` and `fork`, exactly as the homes that ran this protocol before persisted it: pi-durable refuses a token
|
|
10
|
+
* whose semantics differ from the stored record, so adding them here would break every existing run store.
|
|
11
|
+
*/
|
|
12
|
+
export const ResultBudget = defineDoc({ kind: 'boring.result-budget', checkpointWhen: () => false, version: 1, scope: 'conversation', initial: () => ({ misses: 0 }) });
|
|
13
|
+
/** 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. */
|
|
14
|
+
export function resultConfig(value, legacy, name) {
|
|
15
|
+
if (value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value)))
|
|
16
|
+
throw new Error(`agents/${name}: result is { schema?, validate?, repairs? }`);
|
|
17
|
+
const result = (value ?? {});
|
|
18
|
+
if (result.validate !== undefined && (typeof result.validate !== 'string' || !OPERATION.test(result.validate)))
|
|
19
|
+
throw new Error(`agents/${name}: result.validate names an operation`);
|
|
20
|
+
const repairs = result.repairs === undefined ? 2 : result.repairs;
|
|
21
|
+
if (typeof repairs !== 'number' || !Number.isSafeInteger(repairs) || repairs < 0 || repairs > 10)
|
|
22
|
+
throw new Error(`agents/${name}: result.repairs is an integer from 0 to 10`);
|
|
23
|
+
const schema = result.schema === undefined ? legacy?.input : result.schema;
|
|
24
|
+
if (schema !== undefined && (!schema || typeof schema !== 'object' || Array.isArray(schema) || schema.type !== 'object'))
|
|
25
|
+
throw new Error(`agents/${name}: result.schema is an object JSON Schema`);
|
|
26
|
+
return { ...(schema ? { schema: schema } : {}), ...(typeof result.validate === 'string' ? { validate: result.validate } : {}), repairs };
|
|
27
|
+
}
|
|
28
|
+
export const callsOf = (messages) => messages.flatMap(m => m.role === 'assistant' && Array.isArray(m.content) ? m.content.filter(p => p.type === 'toolCall') : []);
|
|
29
|
+
/** 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. */
|
|
30
|
+
export function resultState(card, messages, misses = 0) {
|
|
31
|
+
if (!card.result)
|
|
32
|
+
return { accepted: null, attempts: 0, exhausted: false };
|
|
33
|
+
const all = callsOf(messages), calls = all.filter(c => c.name === card.output?.name);
|
|
34
|
+
const acceptedIds = new Set();
|
|
35
|
+
let accepted = null, error, end;
|
|
36
|
+
for (const message of messages) {
|
|
37
|
+
if (message.role !== 'toolResult' || message.toolName !== card.output?.name)
|
|
38
|
+
continue;
|
|
39
|
+
const checked = message.details?.resultValidation;
|
|
40
|
+
if (checked?.error) {
|
|
41
|
+
error = checked.error;
|
|
42
|
+
end = checked.end ?? 'error';
|
|
43
|
+
}
|
|
44
|
+
if (!message.isError && checked?.valid === true && message.toolCallId !== undefined)
|
|
45
|
+
acceptedIds.add(message.toolCallId);
|
|
46
|
+
}
|
|
47
|
+
for (const call of calls) {
|
|
48
|
+
if (acceptedIds.has(call.id) && all.filter(c => c.id === call.id).length === 1 && card.output && Check(card.output.input, call.arguments))
|
|
49
|
+
accepted = call.arguments;
|
|
50
|
+
}
|
|
51
|
+
const attempts = calls.length + misses;
|
|
52
|
+
return { accepted, ...(error !== undefined ? { error } : {}), ...(end !== undefined ? { end } : {}), attempts, exhausted: attempts > card.result.repairs + 1 || (attempts === card.result.repairs + 1 && !accepted) };
|
|
53
|
+
}
|
|
54
|
+
/** The message of a one-call run: the input as JSON, and how to answer. */
|
|
55
|
+
export function runMessage(card, input) {
|
|
56
|
+
const data = typeof input === 'string' ? input : `Input:\n${JSON.stringify(input ?? {}, null, 2)}`;
|
|
57
|
+
return card.output ? `${data}\n\nReturn the result by calling the tool ${card.output.name} exactly once. No text outside that call.` : data;
|
|
58
|
+
}
|
|
59
|
+
/** A run's answer: only the last host-accepted result call, else the last markdown reply. */
|
|
60
|
+
export function runResult(card, messages) {
|
|
61
|
+
if (card.output) {
|
|
62
|
+
const state = resultState(card, messages ?? []);
|
|
63
|
+
return state.accepted && !state.error && !state.exhausted ? { output: state.accepted } : null;
|
|
64
|
+
}
|
|
65
|
+
let text = '';
|
|
66
|
+
for (const message of messages ?? []) {
|
|
67
|
+
if (message?.role !== 'assistant')
|
|
68
|
+
continue;
|
|
69
|
+
const parts = typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : Array.isArray(message.content) ? message.content : [];
|
|
70
|
+
const said = parts.filter(p => p.type === 'text' && p.text).map(p => p.text).join('');
|
|
71
|
+
if (said)
|
|
72
|
+
text = said;
|
|
73
|
+
}
|
|
74
|
+
return text ? { text } : null;
|
|
75
|
+
}
|
|
76
|
+
/** The model sees the complete result schema as the tool's parameters (nested items, descriptions, required), like a production tool. */
|
|
77
|
+
export function resultParameters(card) {
|
|
78
|
+
return { ...card.output.input, type: 'object', properties: card.output.input.properties ?? {} };
|
|
79
|
+
}
|
|
80
|
+
export const resultDescription = (card) => card.output.description ?? '';
|
|
81
|
+
/** Read the host's whole transcript, not a compacted model context or model-provided evidence. */
|
|
82
|
+
export async function resultMessages(reader, conversationId) {
|
|
83
|
+
const entries = [];
|
|
84
|
+
let cursor;
|
|
85
|
+
do {
|
|
86
|
+
const page = await reader.scanEntries({ conversationId, order: 'ascending' }, 500, cursor);
|
|
87
|
+
entries.push(...page.items);
|
|
88
|
+
cursor = page.next;
|
|
89
|
+
} while (cursor);
|
|
90
|
+
return entries.flatMap(e => (e.model ?? []));
|
|
91
|
+
}
|
|
92
|
+
/** The tool results a run obtained (every call but the result tool's own), with the arguments that actually executed. */
|
|
93
|
+
export function evidenceOf(card, messages) {
|
|
94
|
+
const all = callsOf(messages), calls = all.filter(c => c.name !== card.output?.name);
|
|
95
|
+
return messages.filter(m => m.role === 'toolResult' && calls.some(c => c.id === m.toolCallId && c.name === m.toolName) && all.filter(c => c.id === m.toolCallId).length === 1).map(m => {
|
|
96
|
+
const call = calls.find(c => c.id === m.toolCallId);
|
|
97
|
+
return { name: call.name, arguments: m.details?.hostArguments ?? call.arguments, results: { content: m.content, isError: Boolean(m.isError) } };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Check a result: the schema first (no coercion), then the card's `result.validate` operation, called through `invoke` with the
|
|
102
|
+
* evidence. `phase` is `attempt` while the agent answers and `delivery` for the check made again just before the run is delivered;
|
|
103
|
+
* `key` must be fresh for a delivery check. Throws when the validator answers something else than `{ valid, hint? }`.
|
|
104
|
+
*/
|
|
105
|
+
export async function validateResult(card, output, evidence, invoke, phase, key) {
|
|
106
|
+
if (card.output && !Check(card.output.input, output)) {
|
|
107
|
+
const [, errors] = Errors(card.output.input, output);
|
|
108
|
+
return { valid: false, hint: errors.map(e => `${e.instancePath || '/'}: ${e.message}`).join('; ').slice(0, MAX_HINT) };
|
|
109
|
+
}
|
|
110
|
+
if (!card.result?.validate)
|
|
111
|
+
return { valid: true };
|
|
112
|
+
const checked = await invoke(card.result.validate, { output, evidence, ...(phase === 'delivery' ? { phase } : {}) }, key);
|
|
113
|
+
if (!checked || typeof checked.valid !== 'boolean' || (checked.hint !== undefined && typeof checked.hint !== 'string'))
|
|
114
|
+
throw new Error(`result.validate ${card.result.validate}: expected { valid: boolean, hint?: string }`);
|
|
115
|
+
return { valid: checked.valid, hint: checked.hint?.slice(0, MAX_HINT) ?? 'Return a supported result.' };
|
|
116
|
+
}
|
|
117
|
+
/** `end` of a run from an error thrown while validating: a refusal by the host's authority is `revoked`, anything else `error`. */
|
|
118
|
+
export function validationError(error) {
|
|
119
|
+
const e = error;
|
|
120
|
+
return { error: String(e?.message ?? error).slice(0, 500), end: e?.status === 401 || e?.status === 403 ? 'revoked' : 'error' };
|
|
121
|
+
}
|
|
122
|
+
/** Record the arguments that actually executed (after guards and coercion), never ones invented in the final answer. */
|
|
123
|
+
export function withEvidence(tool) {
|
|
124
|
+
const execute = tool.execute;
|
|
125
|
+
return { ...tool, async execute(args, api, context) {
|
|
126
|
+
const result = await execute.call(tool, args, api, context);
|
|
127
|
+
return { ...result, details: { ...result.details, hostArguments: args } };
|
|
128
|
+
} };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 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
|
|
132
|
+
* repair budget lasts, ask for a corrected result with a native follow-up input (`repair:<conversation>:<n>`, so a replay finds the
|
|
133
|
+
* same submission). A result that passes is checked once more with phase `delivery` before it is returned. Not durable by itself:
|
|
134
|
+
* the caller records the end; a process that dies in the middle ends the run `interrupted`.
|
|
135
|
+
*/
|
|
136
|
+
export async function settleResult(options) {
|
|
137
|
+
const { card, conversation, invoke, context, readResult = runResult } = options;
|
|
138
|
+
let submission = options.submission, previousAttempts = 0;
|
|
139
|
+
for (let attempt = 0;; attempt++) {
|
|
140
|
+
const settled = await submission.wait(context);
|
|
141
|
+
const messages = await conversation.commit(tx => resultMessages(tx, conversation.id), context);
|
|
142
|
+
const budget = await conversation.commit(async (tx) => ({ misses: (await tx.doc(ResultBudget, conversation.id)).misses }), context);
|
|
143
|
+
const state = resultState(card, messages, budget.misses);
|
|
144
|
+
if (state.error)
|
|
145
|
+
return { error: state.error, end: state.end ?? 'error' };
|
|
146
|
+
if (card.output && state.exhausted)
|
|
147
|
+
return { error: `${card.name} returned no result matching tool.json: repair budget exhausted`, end: 'invalid_output' };
|
|
148
|
+
if (settled.status !== 'done')
|
|
149
|
+
return { error: `${card.name} did not answer (${settled.reason ?? settled.status})`, end: INTERRUPTED.has(settled.reason ?? '') ? 'interrupted' : 'error' };
|
|
150
|
+
const result = readResult(card, messages);
|
|
151
|
+
// A card without `result:` keeps its own result reader: no host validation, no repairs.
|
|
152
|
+
if (!card.result)
|
|
153
|
+
return result ? { result } : {};
|
|
154
|
+
const checked = result
|
|
155
|
+
? (card.output ? { valid: true } : await validateResult(card, result.text, evidenceOf(card, messages), invoke, 'attempt', `result:${conversation.id}:markdown:${attempt}`))
|
|
156
|
+
: { valid: false, hint: card.output ? `Return the result using ${card.output.name}.` : 'Return a non-empty answer.' };
|
|
157
|
+
if (checked.valid && result) {
|
|
158
|
+
const validation = { card, evidence: evidenceOf(card, messages) };
|
|
159
|
+
const fresh = await validateResult(card, 'output' in result ? result.output : result.text, validation.evidence, invoke, 'delivery', `result-delivery:${conversation.id}:${crypto.randomUUID()}`);
|
|
160
|
+
return fresh.valid ? { result, validation } : { error: fresh.hint ?? 'Return a supported result.', end: 'invalid_output' };
|
|
161
|
+
}
|
|
162
|
+
const hint = checked.hint ?? 'Return a supported result.';
|
|
163
|
+
const attempts = card.output ? state.attempts + (state.attempts === previousAttempts ? 1 : 0) : attempt + 1;
|
|
164
|
+
if (attempts >= card.result.repairs + 1)
|
|
165
|
+
return { error: hint, end: 'invalid_output' };
|
|
166
|
+
if (card.output && state.attempts === previousAttempts)
|
|
167
|
+
await conversation.commit(async (tx) => { (await tx.doc(ResultBudget, conversation.id)).misses++; }, context);
|
|
168
|
+
previousAttempts = attempts;
|
|
169
|
+
submission = await conversation.submit({ type: 'input', content: `Invalid result: ${hint}\nReturn a corrected result.`, requestId: `repair:${conversation.id}:${attempt + 1}` }, context);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** 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. */
|
|
173
|
+
export function resultExecutor(card, invoke) {
|
|
174
|
+
return async (args, api, context) => {
|
|
175
|
+
const messages = await api.commit(tx => resultMessages(tx, api.conversationId), context);
|
|
176
|
+
const budget = await api.snapshot(ResultBudget, api.conversationId, context);
|
|
177
|
+
const state = resultState(card, messages, budget?.misses ?? 0);
|
|
178
|
+
const calls = callsOf(messages).filter(c => c.id === api.callId);
|
|
179
|
+
const output = calls[0]?.arguments ?? args;
|
|
180
|
+
let checked;
|
|
181
|
+
try {
|
|
182
|
+
checked = calls.length > 1 ? { valid: false, hint: 'Use a unique result call id.' }
|
|
183
|
+
: state.attempts > card.result.repairs + 1 ? { valid: false, hint: 'Result repair budget exhausted' }
|
|
184
|
+
: await validateResult(card, output, evidenceOf(card, messages), invoke, 'attempt', `result:${api.conversationId}:${api.taskId}`);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
return { output: [{ type: 'text', text: 'Result validation failed.' }], isError: true, details: { resultValidation: validationError(error) }, control: { terminate: true } };
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
output: [{ type: 'text', text: checked.valid ? 'Saved.' : `Invalid result: ${checked.hint}` }],
|
|
191
|
+
isError: !checked.valid, details: { resultValidation: { valid: checked.valid } },
|
|
192
|
+
...(!checked.valid && state.attempts >= card.result.repairs + 1 ? { control: { terminate: true } } : {}),
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
}
|
package/dist/runs.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ResultFailure } from './run-result.js';
|
|
2
|
+
/**
|
|
3
|
+
* What a run is, in every home: the ways it ends, the view an operation reads, the usage it reports, and the delivery of its end
|
|
4
|
+
* to the operation that started it. The home keeps the run (a durable task on a Durable Object, a row in a person's SQLite file);
|
|
5
|
+
* these are the parts that must not differ between homes.
|
|
6
|
+
*/
|
|
7
|
+
/** How a run ends. `revoked`: the host's authority was withdrawn (a delivery or validation refused with 401/403). */
|
|
8
|
+
export declare const RUN_ENDS: readonly ["completed", "invalid_output", "cancelled", "interrupted", "revoked", "error"];
|
|
9
|
+
export type RunEnd = typeof RUN_ENDS[number];
|
|
10
|
+
export type RunStatus = 'queued' | 'running' | RunEnd;
|
|
11
|
+
/** The end code of a run that the host's spend cap refused (`error: "over_budget"`), and of a request it refused. */
|
|
12
|
+
export declare const OVER_BUDGET = "over_budget";
|
|
13
|
+
/** What a run used, summed over its model answers: tokens, cost in USD as pi-ai prices it, and the models that answered. */
|
|
14
|
+
export interface RunUsage {
|
|
15
|
+
readonly input: number;
|
|
16
|
+
readonly output: number;
|
|
17
|
+
readonly cacheRead: number;
|
|
18
|
+
readonly cacheWrite: number;
|
|
19
|
+
readonly cost: number;
|
|
20
|
+
readonly models: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
/** A run by its key, as the operation that started it reads it. `usage` is null for a run that never answered. */
|
|
23
|
+
export interface RunView {
|
|
24
|
+
readonly run: string;
|
|
25
|
+
readonly status: RunStatus;
|
|
26
|
+
readonly startedAt?: string;
|
|
27
|
+
readonly output?: unknown;
|
|
28
|
+
readonly usage: RunUsage | null;
|
|
29
|
+
readonly error?: string;
|
|
30
|
+
}
|
|
31
|
+
/** What the delivery operation is called with: the run's end. */
|
|
32
|
+
export interface RunPayload {
|
|
33
|
+
readonly run: string;
|
|
34
|
+
readonly status: RunEnd;
|
|
35
|
+
readonly output?: unknown;
|
|
36
|
+
readonly usage: RunUsage | null;
|
|
37
|
+
readonly error?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Statuses a run can still be in, and ends the view carries an `error` text for. */
|
|
40
|
+
export declare const isEnd: (status: string) => status is RunEnd;
|
|
41
|
+
export declare const endsWithError: (status: string) => boolean;
|
|
42
|
+
/** True when `error` is the spend meter's refusal (`over_budget`, 429), by its `code` or its `extra.code`. */
|
|
43
|
+
export declare function isOverBudget(error: unknown): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* A run's model answers summed from its transcript messages (pi-ai's `usage` on each assistant message); null when none answered.
|
|
46
|
+
* `modelRef` names the model of a message (default: its `model`), for hosts that report `provider:model`; return undefined to skip one.
|
|
47
|
+
*/
|
|
48
|
+
export declare function sumUsage(messages: readonly unknown[], modelRef?: (message: {
|
|
49
|
+
provider?: string;
|
|
50
|
+
model?: string;
|
|
51
|
+
}) => string | undefined): RunUsage | null;
|
|
52
|
+
export interface DeliveryOptions {
|
|
53
|
+
/** The operation that receives the end (`deliver:` of the start). */
|
|
54
|
+
readonly operation: string;
|
|
55
|
+
/** The run's key: the delivery's receipt key is `deliver:<key>`, so the host's receipt makes the delivery once. */
|
|
56
|
+
readonly key: string;
|
|
57
|
+
readonly end: Omit<RunPayload, 'run'>;
|
|
58
|
+
/** Calls the operation as the person whose run it is. Resolve = delivered; throw = refused (the next attempt may pass). */
|
|
59
|
+
readonly invoke: (operation: string, input: unknown, key: string) => Promise<unknown>;
|
|
60
|
+
/** A completed run is checked again just before it is delivered (a `phase: "delivery"` check): its failure becomes the end delivered instead. */
|
|
61
|
+
readonly recheck?: () => Promise<ResultFailure | undefined>;
|
|
62
|
+
/** Told once when the re-check fails, before the delivery, so the home can record the failed end. */
|
|
63
|
+
readonly onFailure?: (failure: ResultFailure) => Promise<void> | void;
|
|
64
|
+
/** Attempts (default 3) and the first wait between two (default 20 ms, doubling). */
|
|
65
|
+
readonly attempts?: number;
|
|
66
|
+
readonly backoffMs?: number;
|
|
67
|
+
}
|
|
68
|
+
export interface DeliveryResult {
|
|
69
|
+
/** False when no attempt was accepted: the run's end stays undelivered and is delivered again later. */
|
|
70
|
+
readonly delivered: boolean;
|
|
71
|
+
/** The end as delivered or last tried: the re-check's failure replaces a completed end. */
|
|
72
|
+
readonly end: Omit<RunPayload, 'run'>;
|
|
73
|
+
readonly failure?: ResultFailure;
|
|
74
|
+
/** The last refusal, when not delivered. */
|
|
75
|
+
readonly error?: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Hand a run's end to the operation that started it, once however often it is tried: attempts are bounded, every one carries the same
|
|
79
|
+
* `deliver:<key>` receipt key, and a completed run is first checked again so a result that stopped being valid is delivered as
|
|
80
|
+
* `invalid_output` (or `revoked`), never as a success. A failed delivery is reported, not dropped: the home retries it later.
|
|
81
|
+
*/
|
|
82
|
+
export declare function deliverRunEnd(options: DeliveryOptions): Promise<DeliveryResult>;
|
|
83
|
+
//# sourceMappingURL=runs.d.ts.map
|
package/dist/runs.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run is, in every home: the ways it ends, the view an operation reads, the usage it reports, and the delivery of its end
|
|
3
|
+
* to the operation that started it. The home keeps the run (a durable task on a Durable Object, a row in a person's SQLite file);
|
|
4
|
+
* these are the parts that must not differ between homes.
|
|
5
|
+
*/
|
|
6
|
+
/** How a run ends. `revoked`: the host's authority was withdrawn (a delivery or validation refused with 401/403). */
|
|
7
|
+
export const RUN_ENDS = Object.freeze(['completed', 'invalid_output', 'cancelled', 'interrupted', 'revoked', 'error']);
|
|
8
|
+
/** The end code of a run that the host's spend cap refused (`error: "over_budget"`), and of a request it refused. */
|
|
9
|
+
export const OVER_BUDGET = 'over_budget';
|
|
10
|
+
/** Statuses a run can still be in, and ends the view carries an `error` text for. */
|
|
11
|
+
export const isEnd = (status) => RUN_ENDS.includes(status);
|
|
12
|
+
const WITH_ERROR = new Set(['error', 'interrupted', 'invalid_output', 'revoked']);
|
|
13
|
+
export const endsWithError = (status) => WITH_ERROR.has(status);
|
|
14
|
+
/** True when `error` is the spend meter's refusal (`over_budget`, 429), by its `code` or its `extra.code`. */
|
|
15
|
+
export function isOverBudget(error) {
|
|
16
|
+
const e = error;
|
|
17
|
+
return (e?.code ?? e?.extra?.code) === OVER_BUDGET;
|
|
18
|
+
}
|
|
19
|
+
const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
|
|
20
|
+
/**
|
|
21
|
+
* A run's model answers summed from its transcript messages (pi-ai's `usage` on each assistant message); null when none answered.
|
|
22
|
+
* `modelRef` names the model of a message (default: its `model`), for hosts that report `provider:model`; return undefined to skip one.
|
|
23
|
+
*/
|
|
24
|
+
export function sumUsage(messages, modelRef = (m) => m.model) {
|
|
25
|
+
let answered = false, input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
|
|
26
|
+
const models = [];
|
|
27
|
+
for (const raw of messages) {
|
|
28
|
+
const message = raw;
|
|
29
|
+
if (message?.role !== 'assistant' || !message.usage)
|
|
30
|
+
continue;
|
|
31
|
+
const u = message.usage;
|
|
32
|
+
answered = true;
|
|
33
|
+
input += count(u.input);
|
|
34
|
+
output += count(u.output);
|
|
35
|
+
cacheRead += count(u.cacheRead);
|
|
36
|
+
cacheWrite += count(u.cacheWrite);
|
|
37
|
+
cost += count(u.cost?.total);
|
|
38
|
+
const ref = typeof message.model === 'string' ? modelRef(message) : undefined;
|
|
39
|
+
if (ref && !models.includes(ref))
|
|
40
|
+
models.push(ref);
|
|
41
|
+
}
|
|
42
|
+
return answered ? { input, output, cacheRead, cacheWrite, cost, models } : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Hand a run's end to the operation that started it, once however often it is tried: attempts are bounded, every one carries the same
|
|
46
|
+
* `deliver:<key>` receipt key, and a completed run is first checked again so a result that stopped being valid is delivered as
|
|
47
|
+
* `invalid_output` (or `revoked`), never as a success. A failed delivery is reported, not dropped: the home retries it later.
|
|
48
|
+
*/
|
|
49
|
+
export async function deliverRunEnd(options) {
|
|
50
|
+
const { operation, key, invoke, recheck, onFailure, attempts = 3, backoffMs = 20 } = options;
|
|
51
|
+
let end = options.end, failure, last;
|
|
52
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
53
|
+
if (attempt)
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, backoffMs * 2 ** (attempt - 1)));
|
|
55
|
+
if (end.status === 'completed' && recheck) {
|
|
56
|
+
let found;
|
|
57
|
+
try {
|
|
58
|
+
found = await recheck();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
// Only a refusal by authority changes the end; any other throw (an outage) leaves it completed, tried again.
|
|
62
|
+
const status = error?.status;
|
|
63
|
+
last = error?.message ?? error;
|
|
64
|
+
if (status !== 401 && status !== 403)
|
|
65
|
+
continue;
|
|
66
|
+
found = { error: String(last).slice(0, 500), end: 'revoked' };
|
|
67
|
+
}
|
|
68
|
+
if (found) {
|
|
69
|
+
failure = found;
|
|
70
|
+
end = { status: found.end, usage: end.usage, error: found.error };
|
|
71
|
+
await onFailure?.(found);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await invoke(operation, { run: key, ...end }, `deliver:${key}`);
|
|
76
|
+
return { delivered: true, end, ...(failure ? { failure } : {}) };
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
last = error?.message ?? error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { delivered: false, end, ...(failure ? { failure } : {}), error: `deliver ${operation}: ${String(last).slice(0, 200)}` };
|
|
83
|
+
}
|
package/dist/schedule.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export declare const ConversationSchedules: import("@earendil-works/pi-durable")
|
|
|
33
33
|
next: number;
|
|
34
34
|
}>;
|
|
35
35
|
/**
|
|
36
|
-
* The native durable task behind one schedule: it
|
|
36
|
+
* The native durable task behind one schedule: it waits on the Harness clock (`runtime.setTimer`), then delivers the instruction to
|
|
37
37
|
* its conversation as a follow-up input whose request id is the schedule id, so a replayed delivery never submits twice. Owned by
|
|
38
38
|
* the conversation as background work: a person stopping the current turn does not cancel it. No second scheduler: Pi's own
|
|
39
39
|
* scheduler runs it, and a restart resumes it.
|
package/dist/schedule.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineDoc, defineExtension, defineTask, defineTool } from '@earendil-works/pi-durable';
|
|
2
2
|
import { Type } from '@earendil-works/pi-ai';
|
|
3
3
|
import { withAbortSignal } from '@earendil-works/chord/context';
|
|
4
|
+
import { waitUntil } from './wait-until.js';
|
|
4
5
|
export const SCHEDULE_TOOL = 'schedule';
|
|
5
6
|
export const LIST_SCHEDULES_TOOL = 'list_schedules';
|
|
6
7
|
export const CANCEL_SCHEDULE_TOOL = 'cancel_schedule';
|
|
@@ -11,7 +12,7 @@ const MAX_INSTRUCTION = 4000;
|
|
|
11
12
|
const TIMEZONE_SUFFIX = /(?:Z|[+-]\d{2}:\d{2})$/i;
|
|
12
13
|
/** Pending schedules of one conversation. Forks start empty: a schedule belongs to the conversation that made it. */
|
|
13
14
|
export const ConversationSchedules = defineDoc({
|
|
14
|
-
kind: 'boring.schedules', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({ items: [], next: 1 }),
|
|
15
|
+
kind: 'boring.schedules', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({ items: [], next: 1 }),
|
|
15
16
|
});
|
|
16
17
|
const remove = (items, id) => { const index = items.findIndex(item => item.id === id); if (index !== -1)
|
|
17
18
|
items.splice(index, 1); };
|
|
@@ -27,7 +28,13 @@ async function sleepUnlessCancelled(runtime, input, context) {
|
|
|
27
28
|
return true;
|
|
28
29
|
watch.start(async (value) => { if (cancelledIn(value))
|
|
29
30
|
cancel.abort(); });
|
|
30
|
-
|
|
31
|
+
const wait = waitUntil(runtime, input.runAt, withAbortSignal(cancel.signal, context));
|
|
32
|
+
try {
|
|
33
|
+
await wait.done;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
wait.disarm();
|
|
37
|
+
}
|
|
31
38
|
return false;
|
|
32
39
|
}
|
|
33
40
|
catch (error) {
|
|
@@ -40,7 +47,7 @@ async function sleepUnlessCancelled(runtime, input, context) {
|
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
49
|
/**
|
|
43
|
-
* The native durable task behind one schedule: it
|
|
50
|
+
* The native durable task behind one schedule: it waits on the Harness clock (`runtime.setTimer`), then delivers the instruction to
|
|
44
51
|
* its conversation as a follow-up input whose request id is the schedule id, so a replayed delivery never submits twice. Owned by
|
|
45
52
|
* the conversation as background work: a person stopping the current turn does not cancel it. No second scheduler: Pi's own
|
|
46
53
|
* scheduler runs it, and a restart resumes it.
|
package/dist/view-open.js
CHANGED
|
@@ -12,7 +12,7 @@ export const VIEW_OPEN_TIMEOUT_MS = 15_000;
|
|
|
12
12
|
export const PRESENT_TOOL = 'present';
|
|
13
13
|
/** One member per assistant entry and tool call, in the calling conversation. */
|
|
14
14
|
const documents = defineDocFamily({
|
|
15
|
-
kind: 'boring.view-open', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', family: true,
|
|
15
|
+
kind: 'boring.view-open', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial', family: true,
|
|
16
16
|
initial: () => ({ view: null }),
|
|
17
17
|
});
|
|
18
18
|
const MAX_REASON = 200;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TaskRuntime } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { Context } from '@earendil-works/chord';
|
|
3
|
+
/**
|
|
4
|
+
* Wait inside the running phase until the Harness clock reaches `at`, on the Harness's own timer (`TaskRuntime.setTimer`, the
|
|
5
|
+
* 1.4 replacement for the removed `TaskRuntime.sleep`). It rejects when `context` is cancelled first. `disarm` frees the timer
|
|
6
|
+
* when something else ends the wait. It is not durable: a restart reruns the phase, which reads its absolute deadline again.
|
|
7
|
+
*/
|
|
8
|
+
export declare function waitUntil(runtime: Pick<TaskRuntime<never, never, never, never>, 'now' | 'setTimer'>, at: number, context: Context): {
|
|
9
|
+
readonly done: Promise<void>;
|
|
10
|
+
readonly disarm: () => void;
|
|
11
|
+
};
|
|
12
|
+
//# sourceMappingURL=wait-until.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wait inside the running phase until the Harness clock reaches `at`, on the Harness's own timer (`TaskRuntime.setTimer`, the
|
|
3
|
+
* 1.4 replacement for the removed `TaskRuntime.sleep`). It rejects when `context` is cancelled first. `disarm` frees the timer
|
|
4
|
+
* when something else ends the wait. It is not durable: a restart reruns the phase, which reads its absolute deadline again.
|
|
5
|
+
*/
|
|
6
|
+
export function waitUntil(runtime, at, context) {
|
|
7
|
+
let disarm = () => { };
|
|
8
|
+
const done = new Promise((resolve, reject) => {
|
|
9
|
+
const signal = context.abortSignal;
|
|
10
|
+
if (signal?.aborted) {
|
|
11
|
+
reject(signal.reason ?? new Error('Wait cancelled'));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const onAbort = () => { disarm(); reject(signal.reason ?? new Error('Wait cancelled')); };
|
|
15
|
+
const disarmTimer = runtime.setTimer(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, Math.max(0, at - runtime.now()));
|
|
16
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
17
|
+
disarm = () => { signal?.removeEventListener('abort', onAbort); disarmTimer(); };
|
|
18
|
+
});
|
|
19
|
+
return { done, disarm: () => disarm() };
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-agent",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.28",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -148,6 +148,26 @@
|
|
|
148
148
|
"./room": {
|
|
149
149
|
"types": "./dist/room.d.ts",
|
|
150
150
|
"import": "./dist/room.js"
|
|
151
|
+
},
|
|
152
|
+
"./run-result": {
|
|
153
|
+
"types": "./dist/run-result.d.ts",
|
|
154
|
+
"import": "./dist/run-result.js"
|
|
155
|
+
},
|
|
156
|
+
"./runs": {
|
|
157
|
+
"types": "./dist/runs.d.ts",
|
|
158
|
+
"import": "./dist/runs.js"
|
|
159
|
+
},
|
|
160
|
+
"./run-ledger": {
|
|
161
|
+
"types": "./dist/run-ledger.d.ts",
|
|
162
|
+
"import": "./dist/run-ledger.js"
|
|
163
|
+
},
|
|
164
|
+
"./run-outbox": {
|
|
165
|
+
"types": "./dist/run-outbox.d.ts",
|
|
166
|
+
"import": "./dist/run-outbox.js"
|
|
167
|
+
},
|
|
168
|
+
"./actions": {
|
|
169
|
+
"types": "./dist/actions.d.ts",
|
|
170
|
+
"import": "./dist/actions.js"
|
|
151
171
|
}
|
|
152
172
|
},
|
|
153
173
|
"dependencies": {
|
|
@@ -157,11 +177,12 @@
|
|
|
157
177
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
|
158
178
|
},
|
|
159
179
|
"peerDependencies": {
|
|
160
|
-
"@earendil-works/pi-durable": "~1.
|
|
161
|
-
"@earendil-works/pi-ai": "~1.
|
|
162
|
-
"@earendil-works/chord": "~1.
|
|
163
|
-
"@hachej/boring-files": "^0.4.
|
|
164
|
-
"@hachej/boring-ui-kit": "^0.4.
|
|
180
|
+
"@earendil-works/pi-durable": "~1.4.0",
|
|
181
|
+
"@earendil-works/pi-ai": "~1.4.0",
|
|
182
|
+
"@earendil-works/chord": "~1.4.0",
|
|
183
|
+
"@hachej/boring-files": "^0.4.28",
|
|
184
|
+
"@hachej/boring-ui-kit": "^0.4.28",
|
|
185
|
+
"typebox": "1.3.27",
|
|
165
186
|
"xlsx": "^0.20.3"
|
|
166
187
|
},
|
|
167
188
|
"peerDependenciesMeta": {
|