@hachej/boring-agent 0.4.25 → 0.4.27
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 +72 -9
- package/dist/actions.d.ts +38 -0
- package/dist/actions.js +158 -0
- package/dist/agents.d.ts +9 -5
- package/dist/agents.js +11 -5
- package/dist/mentions.d.ts +2 -2
- package/dist/mentions.js +2 -2
- 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/self-evolving.d.ts +0 -2
- package/dist/self-evolving.js +8 -41
- package/dist/skills.d.ts +67 -23
- package/dist/skills.js +174 -36
- package/package.json +24 -3
|
@@ -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', 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/self-evolving.d.ts
CHANGED
|
@@ -36,8 +36,6 @@ export interface SelfEvolutionOptions {
|
|
|
36
36
|
readonly skills: readonly Skill[];
|
|
37
37
|
/** Tool names the agent already has from the host; an agent-written tool may not take one. */
|
|
38
38
|
readonly reserved: readonly string[];
|
|
39
|
-
readonly parseSkill: (markdown: string) => Skill;
|
|
40
|
-
readonly skillsExtension: (name: string, skills: readonly Skill[]) => Extension;
|
|
41
39
|
}
|
|
42
40
|
export interface SelfEvolution {
|
|
43
41
|
/** The extension as it is now (the host's skills, `reload` and the agent-written section before the first scan). */
|
package/dist/self-evolving.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineExtension, defineTool } from '@earendil-works/pi-durable';
|
|
2
2
|
import { Type } from '@earendil-works/pi-ai';
|
|
3
|
-
import {
|
|
3
|
+
import { directorySkillSource, skillFile, staticSkills } from './skills.js';
|
|
4
4
|
import { jsonSchemaTool } from './json-schema-tool.js';
|
|
5
5
|
/*
|
|
6
6
|
* Self-evolution (docs/architecture/SELF-EVOLUTION.md, laws SELF-1..4 in this package's README): an agent keeps its own standing
|
|
@@ -103,29 +103,8 @@ async function filesIn(env, directory, suffix, context) {
|
|
|
103
103
|
return listed.error.code === 'not_found' ? 'missing' : { error: listed.error.message };
|
|
104
104
|
return listed.value.filter(entry => entry.kind === 'file' && entry.name.endsWith(suffix)).map(entry => `${directory}/${entry.name}`).sort();
|
|
105
105
|
}
|
|
106
|
-
/** Flat Markdown skills and `SKILL.md` in immediate child folders; companion files are not definitions. */
|
|
107
|
-
async function skillFilesIn(env, context, errors) {
|
|
108
|
-
const listed = await env.listDir(SKILLS, context);
|
|
109
|
-
if (!listed.ok) {
|
|
110
|
-
if (listed.error.code !== 'not_found')
|
|
111
|
-
errors.push({ path: SKILLS, message: listed.error.message });
|
|
112
|
-
return [];
|
|
113
|
-
}
|
|
114
|
-
const paths = listed.value.filter(entry => entry.kind === 'file' && entry.name.endsWith('.md')).map(entry => `${SKILLS}/${entry.name}`);
|
|
115
|
-
for (const entry of listed.value.filter(entry => entry.kind === 'directory')) {
|
|
116
|
-
const folder = `${SKILLS}/${entry.name}`;
|
|
117
|
-
const contents = await env.listDir(folder, context);
|
|
118
|
-
if (!contents.ok) {
|
|
119
|
-
errors.push({ path: folder, message: contents.error.message });
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (contents.value.some(file => file.kind === 'file' && file.name === 'SKILL.md'))
|
|
123
|
-
paths.push(`${folder}/SKILL.md`);
|
|
124
|
-
}
|
|
125
|
-
return paths.sort();
|
|
126
|
-
}
|
|
127
106
|
export function createSelfEvolution(options) {
|
|
128
|
-
const { name
|
|
107
|
+
const { name } = options;
|
|
129
108
|
const hostSkills = [...options.skills];
|
|
130
109
|
const registries = new Set();
|
|
131
110
|
let agentSkills = [];
|
|
@@ -149,7 +128,7 @@ export function createSelfEvolution(options) {
|
|
|
149
128
|
const section = agentInstructionsSection();
|
|
150
129
|
function build(tools) {
|
|
151
130
|
const skills = [...hostSkills, ...agentSkills];
|
|
152
|
-
const offered = skills.length ?
|
|
131
|
+
const offered = skills.length ? staticSkills(skills, { name: `${name}.skills` }).extension : undefined;
|
|
153
132
|
return defineExtension({
|
|
154
133
|
name,
|
|
155
134
|
tools: [reloadTool, ...(offered?.tools ?? []), ...tools.map(toolOf)],
|
|
@@ -169,26 +148,14 @@ export function createSelfEvolution(options) {
|
|
|
169
148
|
else if (read.error.code !== 'not_found')
|
|
170
149
|
errors.push({ path: INSTRUCTIONS, message: read.error.message });
|
|
171
150
|
const skills = [];
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (!text.ok) {
|
|
176
|
-
errors.push({ path, message: text.error.message });
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
let skill;
|
|
180
|
-
try {
|
|
181
|
-
skill = parseSkill(text.value);
|
|
182
|
-
}
|
|
183
|
-
catch (error) {
|
|
184
|
-
errors.push({ path, message: reason(error) });
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
151
|
+
const listing = await directorySkillSource(env, SKILLS, { flat: true }).list(context);
|
|
152
|
+
errors.push(...listing.errors.map(({ path, message }) => ({ path, message })));
|
|
153
|
+
for (const skill of listing.skills) {
|
|
187
154
|
if ([...hostSkills, ...skills].some(other => other.name === skill.name)) {
|
|
188
|
-
errors.push({ path, message: `a skill named ${skill.name} already exists` });
|
|
155
|
+
errors.push({ path: skillFile(skill) ?? SKILLS, message: `a skill named ${skill.name} already exists` });
|
|
189
156
|
continue;
|
|
190
157
|
}
|
|
191
|
-
skills.push(
|
|
158
|
+
skills.push(skill);
|
|
192
159
|
}
|
|
193
160
|
const available = typeof env.exec === 'function';
|
|
194
161
|
const tools = [];
|
package/dist/skills.d.ts
CHANGED
|
@@ -33,21 +33,45 @@ export interface SkillSource {
|
|
|
33
33
|
readonly version: (context: Context) => Promise<string>;
|
|
34
34
|
/** The FileSystem the skills' folders live in; the built-in sources set it. `companionFiles` reads through it. */
|
|
35
35
|
readonly fs?: SkillFileSystem;
|
|
36
|
+
/**
|
|
37
|
+
* A versioned source only (`directorySkillSource(fs, root, { versioned: true })`): `liveSkills` then keeps each conversation on
|
|
38
|
+
* the version it started with until it is reloaded, instead of following `version()`.
|
|
39
|
+
*/
|
|
40
|
+
readonly versions?: SkillVersions;
|
|
41
|
+
}
|
|
42
|
+
/** The versions of a versioned source. A version's folder never changes once written, so it is read once. */
|
|
43
|
+
export interface SkillVersions {
|
|
44
|
+
/**
|
|
45
|
+
* Read the pointer now: the version new conversations start on. When it cannot be used, `error` says why and `version` is the
|
|
46
|
+
* last good one (absent when there was none).
|
|
47
|
+
*/
|
|
48
|
+
readonly current: (context: Context) => Promise<{
|
|
49
|
+
readonly version?: string;
|
|
50
|
+
readonly error?: SkillError;
|
|
51
|
+
}>;
|
|
52
|
+
/** The skills of one version. */
|
|
53
|
+
readonly list: (version: string, context: Context) => Promise<SkillListing>;
|
|
36
54
|
}
|
|
37
55
|
/** The part of Pi's `FileSystem` the built-in sources read through (an `ExecutionEnv` is one). */
|
|
38
56
|
export type SkillFileSystem = Pick<FileSystem, 'readTextFile' | 'listDir' | 'fileInfo'>;
|
|
39
|
-
/**
|
|
40
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Every `<directory>/<name>/SKILL.md`; with `flat`, also each `<directory>/<name>.md`, whose folder is the directory. A missing
|
|
59
|
+
* directory holds no skills. Other files are read only with `companionFiles`.
|
|
60
|
+
*
|
|
61
|
+
* With `versioned`, the skills are in `<directory>/<version>/` and the file `<directory>/current` names the version (one line,
|
|
62
|
+
* for example `v13`). A pointer that is missing, unreadable or names no folder is reported as an error at `<directory>/current`,
|
|
63
|
+
* and the source keeps the last good version.
|
|
64
|
+
*/
|
|
65
|
+
export declare function directorySkillSource(fs: SkillFileSystem, directory: string, options?: {
|
|
66
|
+
readonly flat?: boolean;
|
|
67
|
+
readonly versioned?: boolean;
|
|
68
|
+
}): SkillSource;
|
|
41
69
|
/** One skill file (`SKILL.md` front matter); a missing file holds no skill. Its folder is the file's directory. */
|
|
42
70
|
export declare function fileSkillSource(fs: SkillFileSystem, path: string): SkillSource;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
* Called after each read of a source with every skill it left out (an empty array when none). When `onPublish` refuses the
|
|
48
|
-
* read, it gets the errors of the listing still in effect followed by `{ path: '', message: 'Listing refused by onPublish: <reason>' }`.
|
|
49
|
-
*/
|
|
50
|
-
readonly onDiagnostics?: (errors: readonly SkillError[]) => void;
|
|
71
|
+
/** What both kinds of skills take: the native extension name, the listing text and which skills a conversation sees. */
|
|
72
|
+
export interface SkillsOptions {
|
|
73
|
+
/** The native extension name, one per registry, for example `agent.<id>.skills`. */
|
|
74
|
+
readonly name: string;
|
|
51
75
|
/**
|
|
52
76
|
* Which skills a conversation sees: called once per listing render and per `load_skill` call with Pi's conversation id and
|
|
53
77
|
* every valid skill, it returns the subset that conversation sees (matched by name; anything else returned is ignored).
|
|
@@ -55,17 +79,27 @@ export interface SkillsExtensionOptions {
|
|
|
55
79
|
* a skill that could not be read also answers as unknown (its reason still goes to `onDiagnostics`).
|
|
56
80
|
*/
|
|
57
81
|
readonly visible?: (conversationId: ConversationId, skills: readonly Skill[]) => readonly Skill[] | Promise<readonly Skill[]>;
|
|
82
|
+
/** The listing section's text before the `- name: description` lines. Default: call load_skill first, `/<skill-name>` is explicit. */
|
|
83
|
+
readonly intro?: string;
|
|
84
|
+
}
|
|
85
|
+
/** The options of `liveSkills`: a source is read on use, so it also has a cache age, diagnostics, companion files and a veto. */
|
|
86
|
+
export interface LiveSkillsOptions extends SkillsOptions {
|
|
87
|
+
/** How long a source's copy is used before `version()` is asked again, in milliseconds. Default 5000. */
|
|
88
|
+
readonly maxAgeMs?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Called after each read of a source with every skill it left out (an empty array when none). When `onPublish` refuses the
|
|
91
|
+
* read, it gets the errors of the listing still in effect followed by `{ path: '', message: 'Listing refused by onPublish: <reason>' }`.
|
|
92
|
+
*/
|
|
93
|
+
readonly onDiagnostics?: (errors: readonly SkillError[]) => void;
|
|
58
94
|
/**
|
|
59
95
|
* List the files beside each `SKILL.md` (at any depth, at most 200) in `load_skill`'s answer, and let
|
|
60
96
|
* `load_skill({ name, path })` return one of them as text. Each file is checked when the listing is read: a symbolic link,
|
|
61
97
|
* a file that cannot be read or one that is not UTF-8 text is left out and reported to `onDiagnostics`. `load_skill` checks a
|
|
62
98
|
* listed file again before returning it (the skill's folder, every folder on the way and the file itself, with `fileInfo`,
|
|
63
99
|
* which does not follow links), so a file or folder swapped for a link since the listing is refused. Read through the
|
|
64
|
-
* source's `fs`;
|
|
100
|
+
* source's `fs`; a source without `fs` throws.
|
|
65
101
|
*/
|
|
66
102
|
readonly companionFiles?: boolean;
|
|
67
|
-
/** The listing section's text before the `- name: description` lines. Default: call load_skill first, `/<skill-name>` is explicit. */
|
|
68
|
-
readonly intro?: string;
|
|
69
103
|
/**
|
|
70
104
|
* Called before a newly read listing of a source replaces the current one, with both listings' skills. Return a reason
|
|
71
105
|
* (or throw) to refuse it: the current listing stays, and `onDiagnostics` gets its errors plus `Listing refused by onPublish: <reason>`.
|
|
@@ -73,24 +107,34 @@ export interface SkillsExtensionOptions {
|
|
|
73
107
|
*/
|
|
74
108
|
readonly onPublish?: (next: readonly Skill[], previous: readonly Skill[]) => string | void | Promise<string | void>;
|
|
75
109
|
}
|
|
76
|
-
/** A
|
|
77
|
-
export interface
|
|
110
|
+
/** A live source's extension, with a handle to re-read the source now. */
|
|
111
|
+
export interface LiveSkills {
|
|
78
112
|
readonly extension: Extension;
|
|
79
113
|
/**
|
|
80
114
|
* Read the source now, whatever `maxAgeMs`, and publish what it holds (through `onPublish`). With `name`, only that skill
|
|
81
115
|
* is taken from the new read (added, replaced or removed); the others stay as they were until the source's `version()`
|
|
82
116
|
* changes again, when the next read offers the whole source as usual. Resolves `true` when published, `false` when
|
|
83
117
|
* `onPublish` refused it.
|
|
118
|
+
*
|
|
119
|
+
* A versioned source: read its pointer now and move conversation `conversationId` (every conversation when absent) to that
|
|
120
|
+
* version; `name` is refused. Resolves `false` when the pointer could not be used (the conversations keep their version).
|
|
84
121
|
*/
|
|
85
|
-
readonly refresh: (context: Context, name?: string) => Promise<boolean>;
|
|
122
|
+
readonly refresh: (context: Context, name?: string, conversationId?: ConversationId) => Promise<boolean>;
|
|
86
123
|
}
|
|
87
124
|
/**
|
|
88
|
-
* A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
|
|
89
|
-
*
|
|
90
|
-
|
|
91
|
-
|
|
125
|
+
* A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`. Each skill is
|
|
126
|
+
* validated now: an invalid or duplicate skill throws.
|
|
127
|
+
*/
|
|
128
|
+
export declare function staticSkills(skills: readonly Skill[], options: SkillsOptions): {
|
|
129
|
+
readonly extension: Extension;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* `staticSkills` over a source, plus `refresh(context, name?)` to re-read it now instead of after `maxAgeMs`. The source is read on
|
|
133
|
+
* first use and again, once the copy is older than `maxAgeMs`, when its version changed. What it cannot use is left out and
|
|
134
|
+
* reported, never thrown.
|
|
92
135
|
*/
|
|
93
|
-
export declare function
|
|
94
|
-
|
|
95
|
-
|
|
136
|
+
export declare function liveSkills(source: SkillSource, options: LiveSkillsOptions): LiveSkills;
|
|
137
|
+
export declare const isSkillArray: (skills: readonly Skill[] | SkillSource) => skills is readonly Skill[];
|
|
138
|
+
/** The file a built-in source read this skill from, if any: self-evolution reports a skill it refuses at that path. */
|
|
139
|
+
export declare const skillFile: (skill: Skill) => string | undefined;
|
|
96
140
|
//# sourceMappingURL=skills.d.ts.map
|