@hachej/boring-agent 0.4.35 → 0.4.36
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/dist/agents.d.ts +7 -1
- package/dist/agents.js +8 -2
- package/dist/audit.d.ts +31 -0
- package/dist/audit.js +32 -0
- package/dist/delegate.d.ts +9 -3
- package/dist/delegate.js +4 -3
- package/dist/metering.d.ts +27 -1
- package/dist/metering.js +57 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +13 -1
- package/dist/struggle.d.ts +55 -0
- package/dist/struggle.js +130 -0
- package/package.json +11 -3
package/dist/agents.d.ts
CHANGED
|
@@ -3,9 +3,10 @@ import type { Context } from '@earendil-works/chord';
|
|
|
3
3
|
import type { ExecutionEnv } from '@earendil-works/pi-durable/env';
|
|
4
4
|
import type { SelfEvolutionReport } from './self-evolving.js';
|
|
5
5
|
import type { DelegateOptions } from './delegate.js';
|
|
6
|
+
import type { StruggleOptions } from './struggle.js';
|
|
6
7
|
import type { Skill, SkillSource } from './skills.js';
|
|
7
8
|
export type { SelfEvolutionError, SelfEvolutionReport } from './self-evolving.js';
|
|
8
|
-
export { directorySkillSource, fileSkillSource, liveSkills, parseSkill, staticSkills } from './skills.js';
|
|
9
|
+
export { directorySkillSource, fileSkillSource, liveSkills, parseSkill, skillInFolder, staticSkills } from './skills.js';
|
|
9
10
|
export type { LiveSkills, LiveSkillsOptions, Skill, SkillError, SkillFileSystem, SkillListing, SkillSource, SkillsOptions, SkillVersions } from './skills.js';
|
|
10
11
|
export { jsonSchemaTool } from './json-schema-tool.js';
|
|
11
12
|
export type { JsonObjectSchema, JsonSchemaToolOptions } from './json-schema-tool.js';
|
|
@@ -41,6 +42,11 @@ export interface AgentSpec {
|
|
|
41
42
|
* (docs/architecture/SELF-EVOLUTION.md). Off by default: without it nothing reads `.agent/` (SELF-1).
|
|
42
43
|
*/
|
|
43
44
|
readonly selfEvolving?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The agent reports its own failures (`@hachej/boring-agent/struggle`): the `report_bug` tool and content-free automatic events, on by
|
|
47
|
+
* default. `{ sink, sinkTimeoutMs, agentText }` tune it (default sink: `setStruggleSink`, else one log line); `false` turns it off.
|
|
48
|
+
*/
|
|
49
|
+
readonly struggle?: false | Pick<StruggleOptions, 'sink' | 'sinkTimeoutMs' | 'agentText'>;
|
|
44
50
|
/**
|
|
45
51
|
* The workspace instance this agent works in; names the per-workspace extension `self-evolving:<workspace>`. With `selfEvolving` and
|
|
46
52
|
* no `workspace`, the agent works in a workspace per conversation: `inWorkspace(name)` gives the agent of one workspace.
|
package/dist/agents.js
CHANGED
|
@@ -3,7 +3,8 @@ import { AgentDoc, defineExtension } from '@earendil-works/pi-durable';
|
|
|
3
3
|
import { createSelfEvolution } from './self-evolving.js';
|
|
4
4
|
import { NAME, isSkillArray, liveSkills, staticSkills, validSkill } from './skills.js';
|
|
5
5
|
import { createDelegateTool } from './delegate.js';
|
|
6
|
-
|
|
6
|
+
import { REPORT_BUG_TOOL, createStruggleExtension } from './struggle.js';
|
|
7
|
+
export { directorySkillSource, fileSkillSource, liveSkills, parseSkill, skillInFolder, staticSkills } from './skills.js';
|
|
7
8
|
export { jsonSchemaTool } from './json-schema-tool.js';
|
|
8
9
|
export { createDelegateTool, DELEGATE_DETAILS_KIND, DELEGATE_TOOL } from './delegate.js';
|
|
9
10
|
/** Turn an agent described as data into native extensions and a native agent configuration. */
|
|
@@ -31,7 +32,12 @@ export function defineAgent(spec) {
|
|
|
31
32
|
? (spec.skills.length ? [staticSkills(spec.skills, { name: skillsName }).extension] : [])
|
|
32
33
|
: [liveSkills(spec.skills, { name: skillsName }).extension];
|
|
33
34
|
const listed = (skills) => Object.freeze(skills.map(skill => Object.freeze({ name: skill.name, description: skill.description.trim() })));
|
|
34
|
-
const
|
|
35
|
+
const hostTools = [...tools, ...(spec.extensions ?? []).flatMap(extension => extension.tools ?? [])].map(tool => tool.name);
|
|
36
|
+
const reports = spec.struggle !== false;
|
|
37
|
+
// A self-evolving agent may already keep its own `.agent/tools/report_bug.json`: it gets the automatic events and no tool of ours.
|
|
38
|
+
if (reports)
|
|
39
|
+
own.push(createStruggleExtension({ ...(spec.struggle || {}), agent: spec.id, tool: !spec.selfEvolving && !hostTools.includes(REPORT_BUG_TOOL) }));
|
|
40
|
+
const reserved = hostTools;
|
|
35
41
|
/** The agent as one native configuration: its own extensions, the host's, and with `evolution` the per-workspace one last. */
|
|
36
42
|
function assemble(selected, evolution, inWorkspace) {
|
|
37
43
|
const initial = evolution?.current();
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Extension } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { JsonValue } from '@earendil-works/chord';
|
|
3
|
+
/** One finished tool call. `id` is stable for the call: a call replayed by recovery reports the same `id`, so the sink can drop it. */
|
|
4
|
+
export interface AuditRecord {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
/** Milliseconds since the epoch. */
|
|
7
|
+
readonly at: number;
|
|
8
|
+
readonly user: string;
|
|
9
|
+
readonly conversationId: number;
|
|
10
|
+
readonly tool: string;
|
|
11
|
+
readonly isError: boolean;
|
|
12
|
+
/** What `redact` kept of the call's arguments. Absent without `redact`, or when it threw. */
|
|
13
|
+
readonly arguments?: JsonValue;
|
|
14
|
+
/** What `redact` kept of the result's text. Absent without `redact`, or when it threw. */
|
|
15
|
+
readonly output?: JsonValue;
|
|
16
|
+
/** `redact` threw: the values were dropped, the call is still recorded. */
|
|
17
|
+
readonly redactionFailed?: true;
|
|
18
|
+
}
|
|
19
|
+
export interface AuditExtensionOptions {
|
|
20
|
+
/** The host's audit store. The tool's result waits for it; a sink that fails never fails the tool. */
|
|
21
|
+
readonly sink: (record: AuditRecord) => void | Promise<void>;
|
|
22
|
+
/** Who acts: a fixed id, or read per conversation for a harness shared by several people. */
|
|
23
|
+
readonly user: string | ((conversationId: number) => string);
|
|
24
|
+
/** Called once for the arguments and once for the output. Without it no value is recorded, only which tool ran and how it ended. */
|
|
25
|
+
readonly redact?: (value: JsonValue, field: 'arguments' | 'output', tool: string) => JsonValue;
|
|
26
|
+
/** The extension's native name. Default `boring.audit`. */
|
|
27
|
+
readonly name?: string;
|
|
28
|
+
}
|
|
29
|
+
/** An opt-in native extension: select it in an agent's `extensions` or with `conversation.configure`. It brings no tools and changes no result. */
|
|
30
|
+
export declare function createAuditExtension(options: AuditExtensionOptions): Extension;
|
|
31
|
+
//# sourceMappingURL=audit.d.ts.map
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @hachej/boring-agent/audit: a record of what an agent's tools did, for the host's own audit store. The host owns the store (`sink`), who
|
|
2
|
+
// acted (`user`) and what may be kept (`redact`); this module only observes native tool calls through the `afterTool` hook.
|
|
3
|
+
import { ToolTask, defineExtension, hook } from '@earendil-works/pi-durable';
|
|
4
|
+
const outputText = (result) => (result.output ?? []).map(part => part.type === 'text' ? part.text : '').join('');
|
|
5
|
+
/** An opt-in native extension: select it in an agent's `extensions` or with `conversation.configure`. It brings no tools and changes no result. */
|
|
6
|
+
export function createAuditExtension(options) {
|
|
7
|
+
const { sink, user, redact } = options;
|
|
8
|
+
return defineExtension({
|
|
9
|
+
name: options.name ?? 'boring.audit',
|
|
10
|
+
hooks: [hook(ToolTask, {
|
|
11
|
+
afterTool: async (call, result, api) => {
|
|
12
|
+
const conversationId = Number(api.conversationId);
|
|
13
|
+
let values = {};
|
|
14
|
+
if (redact) {
|
|
15
|
+
try {
|
|
16
|
+
values = { arguments: redact(call.arguments, 'arguments', call.name), output: redact(outputText(result), 'output', call.name) };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
values = { redactionFailed: true };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
await sink({ id: `${conversationId}:${String(api.taskId)}:${call.id}`, at: Date.now(), user: typeof user === 'string' ? user : user(conversationId), conversationId, tool: call.name, isError: result.isError === true, ...values });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
console.error(JSON.stringify({ level: 'error', event: 'audit-sink-failed', tool: call.name, name: error instanceof Error ? error.name : 'Error' }));
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
},
|
|
30
|
+
})],
|
|
31
|
+
});
|
|
32
|
+
}
|
package/dist/delegate.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentChange, ConversationId, EntryId, ToolRegistration, Tx } from '@earendil-works/pi-durable';
|
|
1
|
+
import type { AgentChange, ConversationId, EntryId, TaskId, ToolRegistration, Tx } from '@earendil-works/pi-durable';
|
|
2
2
|
import type { Usage } from '@earendil-works/pi-ai';
|
|
3
3
|
export declare const DELEGATE_TOOL = "delegate";
|
|
4
4
|
/** `DelegateDetails.kind`: how a UI recognises a sub-agent call's details, whatever the tool is named. */
|
|
@@ -76,6 +76,12 @@ export interface DelegateMeter {
|
|
|
76
76
|
/** Called once, after the last `record`, when the call ends. A throw is ignored. */
|
|
77
77
|
readonly close: (end: DelegateEnd) => void | Promise<void>;
|
|
78
78
|
}
|
|
79
|
+
/** Which call made a child, for the host's log lines: the parent's tool call and task, and the request id of the child's input. */
|
|
80
|
+
export interface DelegateCallIds {
|
|
81
|
+
readonly callId: string;
|
|
82
|
+
readonly taskId: TaskId;
|
|
83
|
+
readonly requestId: string;
|
|
84
|
+
}
|
|
79
85
|
export interface DelegateOptions {
|
|
80
86
|
/**
|
|
81
87
|
* The child agent, usually a `defineAgent` result (its `id` and native `agent` change). The child conversation selects exactly its
|
|
@@ -93,7 +99,7 @@ export interface DelegateOptions {
|
|
|
93
99
|
* configured: the host copies what the child must carry from the parent (user, workspace binding, permissions) with `tx`.
|
|
94
100
|
* It runs once per child (never on a replay that finds the existing child); a throw aborts the commit, so no child exists.
|
|
95
101
|
*/
|
|
96
|
-
readonly onChild?: (parent: ConversationId, child: ConversationId, tx: Tx) => void | Promise<void>;
|
|
102
|
+
readonly onChild?: (parent: ConversationId, child: ConversationId, tx: Tx, ids: DelegateCallIds) => void | Promise<void>;
|
|
97
103
|
/** Concurrency, rate, task size and timeout limits (`DelegateLimits`). */
|
|
98
104
|
readonly limits?: DelegateLimits;
|
|
99
105
|
/**
|
|
@@ -102,7 +108,7 @@ export interface DelegateOptions {
|
|
|
102
108
|
* The tool sends the handle each child model response (`DelegateModelEvent`) and closes it when the call ends. A throw fails
|
|
103
109
|
* the call. The kit keeps no log.
|
|
104
110
|
*/
|
|
105
|
-
readonly meter?: (parent: ConversationId, child: ConversationId) => DelegateMeter | Promise<DelegateMeter>;
|
|
111
|
+
readonly meter?: (parent: ConversationId, child: ConversationId, ids: DelegateCallIds) => DelegateMeter | Promise<DelegateMeter>;
|
|
106
112
|
/**
|
|
107
113
|
* Names of the tools a child may have: its enabled tools become the intersection of this list with what it would otherwise
|
|
108
114
|
* get, written as Pi's own tool filter (`pi.agent` `tools`) in the commit that creates it. Unset: no restriction.
|
package/dist/delegate.js
CHANGED
|
@@ -34,6 +34,7 @@ export function createDelegateTool(options) {
|
|
|
34
34
|
const inherited = (await api.agent(context)).extensions.filter(isGuard);
|
|
35
35
|
const agent = withGuards(child, inherited);
|
|
36
36
|
const parent = api.conversationId, ledger = limits === undefined ? undefined : ledgerOf(limits);
|
|
37
|
+
const ids = { callId: api.callId, taskId: api.taskId, requestId: `${name}:${api.taskId}` };
|
|
37
38
|
let opened;
|
|
38
39
|
try {
|
|
39
40
|
opened = await api.commit(async (tx) => {
|
|
@@ -49,7 +50,7 @@ export function createDelegateTool(options) {
|
|
|
49
50
|
await configure(tx, created.id, agent);
|
|
50
51
|
if (options.childTools !== undefined)
|
|
51
52
|
await allowTools(tx, created.id, options.childTools, api);
|
|
52
|
-
await options.onChild?.(parent, created.id, tx);
|
|
53
|
+
await options.onChild?.(parent, created.id, tx, ids);
|
|
53
54
|
return { id: created.id };
|
|
54
55
|
}, context);
|
|
55
56
|
}
|
|
@@ -64,7 +65,7 @@ export function createDelegateTool(options) {
|
|
|
64
65
|
// `end` is set where the call's outcome is known; a throw ends `cancelled` only when the call itself was aborted.
|
|
65
66
|
let meter, end = 'failed', timer;
|
|
66
67
|
try {
|
|
67
|
-
meter = await options.meter?.(parent, childId);
|
|
68
|
+
meter = await options.meter?.(parent, childId, ids);
|
|
68
69
|
const startedAt = await api.memo('delegate.startedAt', Date.now(), context);
|
|
69
70
|
const progress = childProgress(api, childId, meter);
|
|
70
71
|
const details = (status, counts, error) => ({
|
|
@@ -76,7 +77,7 @@ export function createDelegateTool(options) {
|
|
|
76
77
|
const conversation = await api.conversation(childId, context);
|
|
77
78
|
if (conversation === undefined)
|
|
78
79
|
throw new Error(`Delegate conversation ${childId} is missing`);
|
|
79
|
-
const submission = await conversation.submit({ type: 'input', content: args.task, requestId:
|
|
80
|
+
const submission = await conversation.submit({ type: 'input', content: args.task, requestId: ids.requestId }, context);
|
|
80
81
|
// The timeout reuses the cancel path: aborting the child settles its submission `aborted`, reported below as a timeout.
|
|
81
82
|
let timedOut = false;
|
|
82
83
|
if (limits?.timeoutMs !== undefined)
|
package/dist/metering.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context } from '@earendil-works/chord';
|
|
2
|
-
import type { Conversation, Harness } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { Conversation, ConversationId, Harness } from '@earendil-works/pi-durable';
|
|
3
3
|
import type { Models, Usage } from '@earendil-works/pi-ai';
|
|
4
4
|
import { type SqliteConnection, type SqliteSettings } from '@hachej/boring-files/sqlite';
|
|
5
5
|
/** Small flat JSON-safe values the host passes through untouched (a plan, a tenant): at most 16 keys, strings of at most 256 characters. */
|
|
@@ -129,6 +129,32 @@ export interface Meter {
|
|
|
129
129
|
}
|
|
130
130
|
/** The longest `message` preview a reservation carries. */
|
|
131
131
|
export declare const MESSAGE_PREVIEW_LENGTH = 280;
|
|
132
|
+
/** Which delegate call made a child (`DelegateCallIds`; this module stays free of the delegate tool). */
|
|
133
|
+
export interface ChildCallIds {
|
|
134
|
+
readonly callId: string;
|
|
135
|
+
readonly taskId: number;
|
|
136
|
+
readonly requestId: string;
|
|
137
|
+
}
|
|
138
|
+
/** A `DelegateMeter`: pass the function `meterFromSink` returns as `DelegateOptions.meter`. */
|
|
139
|
+
export interface ChildMeter {
|
|
140
|
+
readonly record: (event: {
|
|
141
|
+
readonly entryId: number;
|
|
142
|
+
readonly provider: string;
|
|
143
|
+
readonly model: string;
|
|
144
|
+
readonly usage: Usage;
|
|
145
|
+
}) => void | Promise<void>;
|
|
146
|
+
readonly close: (end: 'done' | 'failed' | 'cancelled' | 'timeout') => void | Promise<void>;
|
|
147
|
+
}
|
|
148
|
+
export interface MeterFromSinkOptions extends Pick<MeterOptions, 'sink' | 'price' | 'models' | 'markup' | 'onError'> {
|
|
149
|
+
/** Who pays for the child, usually read from the parent's identity. */
|
|
150
|
+
readonly scope: MeteringScope | ((parent: ConversationId, child: ConversationId, ids: ChildCallIds) => MeteringScope | Promise<MeteringScope>);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* `DelegateOptions.meter` over a `MeteringSink`: a child's spend goes to the same ledger as its parent's, as one run of the child under the
|
|
154
|
+
* call's request id. A refused reservation throws, which fails the delegate call before the child runs. A rerun reattaching to the child
|
|
155
|
+
* reserves and reports again: the sink is idempotent on `runId` and `usageId`.
|
|
156
|
+
*/
|
|
157
|
+
export declare function meterFromSink(options: MeterFromSinkOptions): (parent: ConversationId, child: ConversationId, ids: ChildCallIds) => Promise<ChildMeter>;
|
|
132
158
|
/** Meter submissions of native conversations into the host's `sink`. */
|
|
133
159
|
export declare function createMeter(options: MeterOptions): Meter;
|
|
134
160
|
/**
|
package/dist/metering.js
CHANGED
|
@@ -20,6 +20,63 @@ export class MeteringRefused extends Error {
|
|
|
20
20
|
const runIdOf = (conversationId, requestId) => `run:${conversationId}:${requestId}`;
|
|
21
21
|
/** The longest `message` preview a reservation carries. */
|
|
22
22
|
export const MESSAGE_PREVIEW_LENGTH = 280;
|
|
23
|
+
/**
|
|
24
|
+
* `DelegateOptions.meter` over a `MeteringSink`: a child's spend goes to the same ledger as its parent's, as one run of the child under the
|
|
25
|
+
* call's request id. A refused reservation throws, which fails the delegate call before the child runs. A rerun reattaching to the child
|
|
26
|
+
* reserves and reports again: the sink is idempotent on `runId` and `usageId`.
|
|
27
|
+
*/
|
|
28
|
+
export function meterFromSink(options) {
|
|
29
|
+
const { sink, onError } = options;
|
|
30
|
+
const price = options.price ?? catalogPrice(options.models);
|
|
31
|
+
const markup = options.markup ?? 1;
|
|
32
|
+
if (!(markup > 0))
|
|
33
|
+
throw new TypeError('markup must be a positive number');
|
|
34
|
+
return async (parent, child, ids) => {
|
|
35
|
+
if (sink.isEnabled?.() === false)
|
|
36
|
+
return { record: () => undefined, close: () => undefined };
|
|
37
|
+
const scope = typeof options.scope === 'function' ? await options.scope(parent, child, ids) : options.scope;
|
|
38
|
+
const run = { ...scope, conversationId: Number(child), requestId: ids.requestId, runId: runIdOf(Number(child), ids.requestId) };
|
|
39
|
+
const reserved = await sink.reserveRun({ ...run, kind: 'input', message: '', content: '' });
|
|
40
|
+
if ('kind' in reserved)
|
|
41
|
+
throw new MeteringRefused(reserved.reason);
|
|
42
|
+
const reservation = reserved.reservationId ? { reservationId: reserved.reservationId } : {};
|
|
43
|
+
// A rerun after a crash reattaches here. A run the ledger already finished must not take the replayed usage on top of a charged hold.
|
|
44
|
+
if (sink.openRuns && !(await sink.openRuns()).some(open => open.runId === run.runId))
|
|
45
|
+
return { record: () => undefined, close: () => undefined };
|
|
46
|
+
let recorded = 0, lost = false, unpriced = false;
|
|
47
|
+
return {
|
|
48
|
+
record: async (event) => {
|
|
49
|
+
const model = { provider: event.provider, id: event.model };
|
|
50
|
+
try {
|
|
51
|
+
const { billedMicros } = await sink.recordUsage({ ...run, ...reservation, usageId: `entry:${child}:${event.entryId}`, model, bucket: `model:${event.provider}/${event.model}`, usage: event.usage, amountMicros: Math.ceil(Math.max(0, price(event.usage, model)) * markup) });
|
|
52
|
+
recorded++;
|
|
53
|
+
if (billedMicros === 0 && !isZero(event.usage))
|
|
54
|
+
unpriced = true;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
lost = true;
|
|
58
|
+
onError?.(`recordUsage failed (${run.runId})`, error);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
close: async (end) => {
|
|
62
|
+
try {
|
|
63
|
+
if (lost)
|
|
64
|
+
await sink.releaseRun({ ...run, ...reservation, reason: 'usage-write-failed' });
|
|
65
|
+
// Tokens that priced to nothing are work it cannot account for, as in `createMeter`: the hold is charged, not settled as free.
|
|
66
|
+
else if (unpriced)
|
|
67
|
+
await sink.releaseRun({ ...run, ...reservation, reason: 'fallback-charge' });
|
|
68
|
+
else if (end === 'done' || recorded > 0)
|
|
69
|
+
await sink.settleRun({ ...run, ...reservation, status: end === 'done' ? 'done' : 'unanswered' });
|
|
70
|
+
else
|
|
71
|
+
await sink.releaseRun({ ...run, ...reservation, reason: end === 'failed' ? 'error-before-usage' : 'cancelled' });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
onError?.(`closing the child's run failed (${run.runId})`, error);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
}
|
|
23
80
|
function previewOf(content) {
|
|
24
81
|
const parts = typeof content === 'string' ? [content] : Array.isArray(content)
|
|
25
82
|
? content.map(part => part?.type === 'text' && typeof part.text === 'string' ? part.text : part?.type === 'image' ? '[image]' : '') : [];
|
package/dist/skills.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface Skill {
|
|
|
9
9
|
readonly description: string;
|
|
10
10
|
readonly body: string;
|
|
11
11
|
}
|
|
12
|
+
/** Say which folder a skill's relative paths start from, for a skill built without a directory source. `load_skill` names it to the agent. */
|
|
12
13
|
export declare function skillInFolder(skill: Skill, folder: string): Skill;
|
|
13
14
|
/** Parse a `SKILL.md`-style document: `---` front matter with `name` and `description`, then the body. */
|
|
14
15
|
export declare function parseSkill(markdown: string): Skill;
|
|
@@ -79,6 +80,12 @@ export interface SkillsOptions {
|
|
|
79
80
|
* a skill that could not be read also answers as unknown (its reason still goes to `onDiagnostics`).
|
|
80
81
|
*/
|
|
81
82
|
readonly visible?: (conversationId: ConversationId, skills: readonly Skill[]) => readonly Skill[] | Promise<readonly Skill[]>;
|
|
83
|
+
/**
|
|
84
|
+
* A `load_skill` call for a name this conversation does not see: the host may answer with a skill it keeps out of the listing, or
|
|
85
|
+
* undefined for an unknown name. It wins over `visible`, so it is the host's own permission check. Called only with a valid skill name.
|
|
86
|
+
* A skill of another name, an invalid one or a throw answers as unknown.
|
|
87
|
+
*/
|
|
88
|
+
readonly loadUnlisted?: (conversationId: ConversationId, name: string) => Skill | undefined | Promise<Skill | undefined>;
|
|
82
89
|
/** The listing section's text before the `- name: description` lines. Default: call load_skill first, `/<skill-name>` is explicit. */
|
|
83
90
|
readonly intro?: string;
|
|
84
91
|
}
|
package/dist/skills.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Type } from '@earendil-works/pi-ai';
|
|
|
3
3
|
/** Names of skills and agents: a lowercase letter or digit, then lowercase letters, digits, `.`, `_` or `-` (at most 64). */
|
|
4
4
|
export const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
5
5
|
const skillFolders = new WeakMap();
|
|
6
|
+
/** Say which folder a skill's relative paths start from, for a skill built without a directory source. `load_skill` names it to the agent. */
|
|
6
7
|
export function skillInFolder(skill, folder) {
|
|
7
8
|
skillFolders.set(skill, folder);
|
|
8
9
|
return skill;
|
|
@@ -413,6 +414,17 @@ function skillsExtension(live, options, fs) {
|
|
|
413
414
|
const names = new Set((await visible(at(), all)).map(skill => skill.name));
|
|
414
415
|
return all.filter(skill => names.has(skill.name));
|
|
415
416
|
}
|
|
417
|
+
async function unlisted(conversationId, name) {
|
|
418
|
+
if (!options.loadUnlisted || !NAME.test(name))
|
|
419
|
+
return undefined;
|
|
420
|
+
try {
|
|
421
|
+
const found = await options.loadUnlisted(conversationId, name);
|
|
422
|
+
return found?.name === name ? validSkill(found) : undefined;
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
416
428
|
const parameters = fs
|
|
417
429
|
? Type.Object({ name: Type.String(), path: Type.Optional(Type.String({ description: 'A companion file of the skill, relative to its folder.' })) }, { additionalProperties: false })
|
|
418
430
|
: Type.Object({ name: Type.String() }, { additionalProperties: false });
|
|
@@ -437,7 +449,7 @@ function skillsExtension(live, options, fs) {
|
|
|
437
449
|
({ known, errors } = await live.miss(context));
|
|
438
450
|
shown = await seen(known, () => api.conversationId);
|
|
439
451
|
}
|
|
440
|
-
const skill = shown.find(candidate => candidate.name === args.name);
|
|
452
|
+
const skill = shown.find(candidate => candidate.name === args.name) ?? await unlisted(api?.conversationId, args.name);
|
|
441
453
|
if (!skill) {
|
|
442
454
|
const failed = visible ? undefined : errors.find(error => error.name === args.name);
|
|
443
455
|
return text(failed ? `Skill "${args.name}" could not be loaded (${failed.path}): ${failed.message}` : `Unknown skill "${args.name}". Available: ${shown.map(entry => entry.name).join(', ')}`);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Extension } from '@earendil-works/pi-durable';
|
|
2
|
+
export declare const REPORT_BUG_TOOL = "report_bug";
|
|
3
|
+
/**
|
|
4
|
+
* `tool_error`: a tool answered with an error. `tool_retry`: since the person's last message, one tool ran 3 times with the same arguments,
|
|
5
|
+
* or failed 3 times. `rephrase`: the message shares at least 75% of its words with one of the person's 2 previous messages (3 words or more).
|
|
6
|
+
* `correction`: the message opens by saying the agent is wrong; a bare « non » answering a question does not count. `abandoned`: the person
|
|
7
|
+
* stopped a run mid-task, seen when the attempt ends or at the next request. `report_bug`: the agent called the tool.
|
|
8
|
+
*/
|
|
9
|
+
export type StruggleKind = 'tool_error' | 'tool_retry' | 'rephrase' | 'correction' | 'abandoned' | 'report_bug';
|
|
10
|
+
export declare const BUG_CATEGORIES: readonly ["tool_failed", "impossible", "person_says_wrong"];
|
|
11
|
+
export type BugCategory = (typeof BUG_CATEGORIES)[number];
|
|
12
|
+
/** Content-free: never the person's words, and no text a model wrote unless the host turned on `agentText`. */
|
|
13
|
+
export interface AgentStruggle {
|
|
14
|
+
readonly event: 'agent.struggle';
|
|
15
|
+
readonly kind: StruggleKind;
|
|
16
|
+
readonly agent: string;
|
|
17
|
+
/** Stable for one agent, kind and tool: the host files one task per fingerprint. */
|
|
18
|
+
readonly fingerprint: string;
|
|
19
|
+
readonly conversationId: number;
|
|
20
|
+
readonly taskId: number;
|
|
21
|
+
readonly tool?: string;
|
|
22
|
+
/** On `report_bug`: which of `BUG_CATEGORIES` the agent chose. */
|
|
23
|
+
readonly category?: BugCategory;
|
|
24
|
+
/** Never set here: a host that knows the request adds it in its sink. */
|
|
25
|
+
readonly req_id?: string;
|
|
26
|
+
/** With `agentText` only. */
|
|
27
|
+
readonly summary?: string;
|
|
28
|
+
readonly tried?: string;
|
|
29
|
+
}
|
|
30
|
+
export type StruggleSink = (struggle: AgentStruggle) => void | Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* The host's sink for every agent of this process that names none: the agent home calls it once, so apps write no code. One sink per
|
|
33
|
+
* process: a process serving several tenants gives each agent its own through `defineAgent({ struggle: { sink } })`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function setStruggleSink(sink: StruggleSink | undefined): void;
|
|
36
|
+
export interface StruggleOptions {
|
|
37
|
+
/** The agent's id, in the fingerprint. */
|
|
38
|
+
readonly agent: string;
|
|
39
|
+
/** Default: the sink of `setStruggleSink`, else one log line. A throw is ignored: reporting never fails the agent. */
|
|
40
|
+
readonly sink?: StruggleSink;
|
|
41
|
+
/** How long the agent waits for the sink, in milliseconds. Default 2000; a late sink keeps running unwatched. */
|
|
42
|
+
readonly sinkTimeoutMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Let `report_bug` ask for the agent's own `summary` and `tried`, scrubbed of emails and long numbers. Off by default: free text a model
|
|
45
|
+
* writes can carry a name or a diagnosis no scrub catches, so turn it on only where such text may reach the sink.
|
|
46
|
+
*/
|
|
47
|
+
readonly agentText?: boolean;
|
|
48
|
+
/** False when the agent already has a tool named `report_bug`. Default true. */
|
|
49
|
+
readonly tool?: boolean;
|
|
50
|
+
/** Default `agent.<agent>.struggle`. */
|
|
51
|
+
readonly name?: string;
|
|
52
|
+
}
|
|
53
|
+
/** The S6 extension: the `report_bug` tool and the automatic events. It changes no result and no request. */
|
|
54
|
+
export declare function createStruggleExtension(options: StruggleOptions): Extension;
|
|
55
|
+
//# sourceMappingURL=struggle.d.ts.map
|
package/dist/struggle.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @hachej/boring-agent/struggle: an app's agent reports its own failures (fleet standard S6), so the fleet can fix them without a person
|
|
2
|
+
// noticing. `defineAgent` selects this extension by default. It reads run events through native hooks and sends only kinds, tool names and
|
|
3
|
+
// ids to the host's sink: the person's words are read to classify them and never leave this module.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { GenerationTask, ToolTask, defineExtension, hook } from '@earendil-works/pi-durable';
|
|
6
|
+
import { jsonSchemaTool } from './json-schema-tool.js';
|
|
7
|
+
export const REPORT_BUG_TOOL = 'report_bug';
|
|
8
|
+
export const BUG_CATEGORIES = ['tool_failed', 'impossible', 'person_says_wrong'];
|
|
9
|
+
/** Without a host sink an event is one structured log line, so nothing is lost before a host wires one. */
|
|
10
|
+
const logLine = struggle => { console.log(JSON.stringify({ level: 'warn', ...struggle })); };
|
|
11
|
+
let hostSink;
|
|
12
|
+
/**
|
|
13
|
+
* The host's sink for every agent of this process that names none: the agent home calls it once, so apps write no code. One sink per
|
|
14
|
+
* process: a process serving several tenants gives each agent its own through `defineAgent({ struggle: { sink } })`.
|
|
15
|
+
*/
|
|
16
|
+
export function setStruggleSink(sink) { hostSink = sink; }
|
|
17
|
+
const MAX_TEXT = 200, MAX_SEEN = 500, SINK_TIMEOUT_MS = 2000;
|
|
18
|
+
const scrub = (text) => text.replace(/\S+@\S+/g, '[email]').replace(/\d[\d\s.-]{2,}\d/g, '[number]').slice(0, MAX_TEXT);
|
|
19
|
+
const userText = (message) => message.role !== 'user' ? '' : typeof message.content === 'string' ? message.content
|
|
20
|
+
: message.content.map(part => part.type === 'text' ? part.text : '').join(' ');
|
|
21
|
+
const asked = (message) => message?.role === 'assistant'
|
|
22
|
+
&& message.content.map(part => part.type === 'text' ? part.text : '').join('').trim().endsWith('?');
|
|
23
|
+
/** The person's words only: the block a point-and-speak message or a mention adds is not what they said. */
|
|
24
|
+
const words = (text) => new Set(text.replace(/^<target>.*?<\/target>/s, '').replace(/<file[\s\S]*?<\/file>/g, '').toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []);
|
|
25
|
+
function rephrased(text, earlier) {
|
|
26
|
+
const said = words(text);
|
|
27
|
+
if (said.size < 3)
|
|
28
|
+
return false;
|
|
29
|
+
return earlier.some(other => {
|
|
30
|
+
const before = words(other);
|
|
31
|
+
const shared = [...said].filter(word => before.has(word)).length;
|
|
32
|
+
return shared / new Set([...said, ...before]).size >= 0.75;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const WRONG = /^\s*((no|non)[,.! ]+)?(c['’]est faux|c['’]est pas (ça|ca)|ce n['’]est pas (ça|ca)|tu te trompes|vous vous trompez|faux\b|that['’]s wrong|you['’]re wrong|wrong\b|incorrect\b|not what i asked)/i;
|
|
36
|
+
const BARE_NO = /^\s*(no|non)\s*[.!]*\s*$/i;
|
|
37
|
+
/** The S6 extension: the `report_bug` tool and the automatic events. It changes no result and no request. */
|
|
38
|
+
export function createStruggleExtension(options) {
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const emit = async (once, ids, kind, fields = {}) => {
|
|
41
|
+
const key = `${String(ids.conversationId)}:${once}`;
|
|
42
|
+
if (seen.has(key))
|
|
43
|
+
return;
|
|
44
|
+
if (seen.size >= MAX_SEEN)
|
|
45
|
+
seen.delete(seen.values().next().value);
|
|
46
|
+
seen.add(key);
|
|
47
|
+
const fingerprint = createHash('sha256').update(JSON.stringify([options.agent, kind, fields.tool ?? null])).digest('hex').slice(0, 16);
|
|
48
|
+
// Hooks run before the result commits: a sink that hangs must not hold the agent.
|
|
49
|
+
let timer;
|
|
50
|
+
try {
|
|
51
|
+
await Promise.race([
|
|
52
|
+
(options.sink ?? hostSink ?? logLine)({ event: 'agent.struggle', kind, agent: options.agent, fingerprint, conversationId: Number(ids.conversationId), taskId: Number(ids.taskId), ...fields }),
|
|
53
|
+
new Promise(resolve => { timer = setTimeout(resolve, options.sinkTimeoutMs ?? SINK_TIMEOUT_MS); }),
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
catch { /* the host's sink owns its failures */ }
|
|
57
|
+
finally {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const tool = jsonSchemaTool({
|
|
62
|
+
name: REPORT_BUG_TOOL,
|
|
63
|
+
description: "Tell the app's team that something is broken: a tool failed, something you should be able to do is impossible, or the person says you are wrong.",
|
|
64
|
+
parameters: { type: 'object', properties: {
|
|
65
|
+
category: { type: 'string', enum: BUG_CATEGORIES },
|
|
66
|
+
tool: { type: 'string', description: 'The name of the tool involved, if any.' },
|
|
67
|
+
...(options.agentText ? {
|
|
68
|
+
summary: { type: 'string', description: "What is broken, in one short general sentence: never the person's words, name, health, money or any personal value." },
|
|
69
|
+
tried: { type: 'string', description: 'What you tried, in general terms, with no personal value.' },
|
|
70
|
+
} : {}),
|
|
71
|
+
}, required: ['category'] },
|
|
72
|
+
execute: async (args, api, context) => {
|
|
73
|
+
// The model writes these: only a tool the agent really has, and an enum, may leave. A name it made up could carry anything.
|
|
74
|
+
const known = (await api.agent(context)).extensions.some(extension => extension.tools?.some(each => each.name === args.tool));
|
|
75
|
+
await emit(`bug:${api.callId}`, api, 'report_bug', {
|
|
76
|
+
...(BUG_CATEGORIES.includes(args.category) ? { category: args.category } : {}),
|
|
77
|
+
...(args.tool && known ? { tool: args.tool } : {}),
|
|
78
|
+
...(options.agentText && args.summary ? { summary: scrub(args.summary) } : {}),
|
|
79
|
+
...(options.agentText && args.tried ? { tried: scrub(args.tried) } : {}),
|
|
80
|
+
});
|
|
81
|
+
return { output: [{ type: 'text', text: 'Reported to the team. Carry on with the person.' }] };
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
return defineExtension({
|
|
85
|
+
name: options.name ?? `agent.${options.agent}.struggle`,
|
|
86
|
+
tools: options.tool === false ? [] : [tool],
|
|
87
|
+
hooks: [
|
|
88
|
+
hook(ToolTask, {
|
|
89
|
+
afterTool: async (call, result, api) => {
|
|
90
|
+
if (result.isError === true && call.name !== REPORT_BUG_TOOL)
|
|
91
|
+
await emit(`error:${call.id}`, api, 'tool_error', { tool: call.name });
|
|
92
|
+
return undefined;
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
hook(GenerationTask, {
|
|
96
|
+
afterResponse: async (message, api) => { if (message.stopReason === 'aborted')
|
|
97
|
+
await emit(`stop:${String(api.taskId)}`, api, 'abandoned'); },
|
|
98
|
+
beforeRequest: async ({ messages }, api) => {
|
|
99
|
+
const users = messages.flatMap((message, index) => message.role === 'user' ? [index] : []);
|
|
100
|
+
const last = users.at(-1);
|
|
101
|
+
if (last === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
if (last === messages.length - 1) {
|
|
104
|
+
const text = userText(messages[last]);
|
|
105
|
+
const previous = messages[last - 1];
|
|
106
|
+
if (WRONG.test(text) || (BARE_NO.test(text) && !asked(previous)))
|
|
107
|
+
await emit(`wrong:${last}`, api, 'correction');
|
|
108
|
+
if (rephrased(text, users.slice(-3, -1).map(index => userText(messages[index]))))
|
|
109
|
+
await emit(`rephrase:${last}`, api, 'rephrase');
|
|
110
|
+
if (previous?.role === 'assistant' && previous.stopReason === 'aborted')
|
|
111
|
+
await emit(`stopped:${last}`, api, 'abandoned');
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const same = new Map(), failed = new Map();
|
|
115
|
+
const bump = (counts, key) => { const count = (counts.get(key) ?? 0) + 1; counts.set(key, count); return count; };
|
|
116
|
+
for (const message of messages.slice(last + 1)) {
|
|
117
|
+
if (message.role === 'assistant') {
|
|
118
|
+
for (const part of message.content)
|
|
119
|
+
if (part.type === 'toolCall' && bump(same, JSON.stringify([part.name, part.arguments])) === 3)
|
|
120
|
+
await emit(`retry:${last}:${part.name}`, api, 'tool_retry', { tool: part.name });
|
|
121
|
+
}
|
|
122
|
+
else if (message.role === 'toolResult' && message.isError && bump(failed, message.toolName) === 3)
|
|
123
|
+
await emit(`retry:${last}:${message.toolName}`, api, 'tool_retry', { tool: message.toolName });
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-agent",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.36",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -69,6 +69,14 @@
|
|
|
69
69
|
"types": "./dist/file-types.d.ts",
|
|
70
70
|
"import": "./dist/file-types.js"
|
|
71
71
|
},
|
|
72
|
+
"./audit": {
|
|
73
|
+
"types": "./dist/audit.d.ts",
|
|
74
|
+
"import": "./dist/audit.js"
|
|
75
|
+
},
|
|
76
|
+
"./struggle": {
|
|
77
|
+
"types": "./dist/struggle.d.ts",
|
|
78
|
+
"import": "./dist/struggle.js"
|
|
79
|
+
},
|
|
72
80
|
"./ask-user": {
|
|
73
81
|
"types": "./dist/ask-user.d.ts",
|
|
74
82
|
"import": "./dist/ask-user.js"
|
|
@@ -180,8 +188,8 @@
|
|
|
180
188
|
"@earendil-works/pi-durable": "~1.4.0",
|
|
181
189
|
"@earendil-works/pi-ai": "~1.4.0",
|
|
182
190
|
"@earendil-works/chord": "~1.4.0",
|
|
183
|
-
"@hachej/boring-files": "^0.4.
|
|
184
|
-
"@hachej/boring-ui-kit": "^0.4.
|
|
191
|
+
"@hachej/boring-files": "^0.4.36",
|
|
192
|
+
"@hachej/boring-ui-kit": "^0.4.36",
|
|
185
193
|
"typebox": "1.3.27",
|
|
186
194
|
"xlsx": "^0.20.3"
|
|
187
195
|
},
|