@hachej/boring-agent 0.4.20 → 0.4.22

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 CHANGED
@@ -353,11 +353,22 @@ Each evidence entry must link to a completed native ToolTask, its original assis
353
353
 
354
354
  With self-evolution, workspace skills may be flat `.agent/skills/<name>.md` files or `.agent/skills/<name>/SKILL.md` folders. Only `SKILL.md` defines a skill inside a folder; other Markdown and deeper files are companions, not skills. `load_skill` returns the body followed by “This skill's folder is `.agent/skills/<name>`; paths in it are relative to that folder.” (For flat files the folder is `.agent/skills`.) A skill can say `see portions.md` or `see tables/2026.csv`; the agent reads that path under the returned folder with Pi's native `read`, through the same workspace provider. Scanning and loading do not read the companions. No special companion folder, new file tool or additional access is granted. Host-given `Skill` data stays `{ name, description, body }`: with no source folder, `load_skill` returns its body unchanged.
355
355
 
356
+ ## Live skills
357
+
358
+ `createSkillsExtension(name, skills, options?)` also takes a `SkillSource` instead of an array, so skills added, edited or removed after start reach the agent without rebuilding the extension. A source is `{ list(context), version(context) }`: `list` returns `{ skills, errors }`, `version` a cheap token that changes when the skills do. Two are built in, and both read through the workspace's Pi `FileSystem` (an `ExecutionEnv` is one; pass the workspace provider's instance, never `node:fs`):
359
+
360
+ - `directorySkillSource(fs, 'skills')`: every `skills/<name>/SKILL.md`, its folder passed to `load_skill` as for self-evolution. A missing directory holds no skills; folders without `SKILL.md` and companion files are not read. Its version is the folder names with each `SKILL.md`'s `mtimeMs` and size (one `listDir` and one `fileInfo` per folder).
361
+ - `fileSkillSource(fs, 'notes/triage.md')`: one skill file, its folder the file's directory; a missing file holds no skill. Its version is the file's `mtimeMs` and size.
362
+
363
+ The source is read on first use. After that the listing section and `load_skill` use the cached copy until it is older than `options.maxAgeMs` (default 5000), then ask `version()` and read `list()` again only when it changed. There is no watcher or timer: the check happens on use, and concurrent uses share one read. What a source returns is checked with `validSkill` and the duplicate-name rule (the first skill of a name wins). A skill that fails, or a file that cannot be read or parsed, is left out instead of taking the extension down: `options.onDiagnostics(errors)` receives every `SkillError` (`{ path, name?, message }`) after each read (an empty array once all are fine), and `load_skill` asked for the left-out name answers `Skill "<name>" could not be loaded (<path>): <reason>`. A source whose `list`/`version` throws is a host bug: the section keeps the text already shown (Pi's `renderSections`) and `load_skill` fails. A fixed array works as before: validated once, an invalid or duplicate skill throws. With no skills, the section is omitted.
364
+
365
+ Pi renders every extension section before each model request (`PromptSection.render`), so a refreshed listing reaches the next request; Pi's `planSystemEntries` writes nothing when the text is unchanged and a minimal `pi.system` section patch when it changed. Prompt cache: on a model that accepts system messages mid-conversation the patch is appended and the cached prefix stays valid; on one that does not, Pi collapses the system messages into one leading prompt, so every listing change rewrites the start of the prompt and misses the provider's prompt cache once. Keep `maxAgeMs` coarse and edits to skill descriptions deliberate. Pi itself has no skills loader in the packages this one depends on (`pi-durable` and `pi-ai`), so this is a host concern built on its `FileSystem` and section seams.
366
+
356
367
  ## Sub-agents and schedules
357
368
 
358
369
  These follow [Pi Durable on Cloudflare](https://earendil.com/posts/pi-durable-on-cloudflare/) and stay host-agnostic: a Node process and a Durable Object mount the same tools.
359
370
 
360
- **Delegate.** `createDelegateTool({ agent, name?, description? })` from `@hachej/boring-agent/agents` is Pi's documented subagent pattern (pi-durable README, "Abort and Subagents"; pi-durable ships it as an example, not an export) as a tool factory. `delegate({ task })` creates a child conversation owned by the calling tool task (`ownership: { kind: 'task', taskId }`), configured as the child agent alone (its extensions, model and instructions; the inherited tool filters reset), submits the task with request id `<name>:<taskId>` and returns only the child's final text, with `details` (`DelegateDetails`, below) naming the child. It is replay-safe: a rerun after a crash or eviction finds its child through Pi's ownership index (`scanConversations({ ownerTaskId })`) and its submission through the request id, so it never starts a second child or turn. Aborting the call aborts the child. With `defineAgent`, declare `delegates: [{ agent: researcher }]`: each becomes a tool of the agent and `install(registry)` installs the child's extensions as well. A raw-Pi host installs the child agent's extensions itself.
371
+ **Delegate.** `createDelegateTool({ agent, name?, description?, onChild?, limits?, meter?, childTools? })` from `@hachej/boring-agent/agents` is Pi's documented subagent pattern (pi-durable README, "Abort and Subagents"; pi-durable ships it as an example, not an export) as a tool factory. `delegate({ task })` creates a child conversation owned by the calling tool task (`ownership: { kind: 'task', taskId }`), configured as the child agent alone (its extensions, model and instructions; the inherited tool filters reset), submits the task with request id `<name>:<taskId>` and returns only the child's final text, with `details` (`DelegateDetails`, below) naming the child. It is replay-safe: a rerun after a crash or eviction finds its child through Pi's ownership index (`scanConversations({ ownerTaskId })`) and its submission through the request id, so it never starts a second child or turn. Aborting the call aborts the child. With `defineAgent`, declare `delegates: [{ agent: researcher }]`: each becomes a tool of the agent and `install(registry)` installs the child's extensions as well. A raw-Pi host installs the child agent's extensions itself.
361
372
 
362
373
  **Child identity.** The kit copies no identity onto a child; the host decides what a child carries. Pass `onChild(parent, child, tx)` in `DelegateOptions` (or a `delegates` entry): it runs inside the same native commit (`ToolExecutionApi.commit`, Pi's `Tx`) that creates and configures the child, so the child never exists without what it writes, and a throw aborts the commit and leaves no child (the delegate call returns an error). It runs once per child, not on a replay that finds the existing child. Read and write only through `tx`:
363
374
 
@@ -370,6 +381,30 @@ const delegates = [{ agent: researcher, onChild: async (parent, child, tx) => {
370
381
 
371
382
  **Sub-agent card data.** Every delegate call's `details` is a stable `DelegateDetails` (exported from `@hachej/boring-agent/agents`, with `DELEGATE_DETAILS_KIND` and `DelegateStatus`): `{ kind: 'boring.delegate', version: 1, agent, conversationId, status, startedAt, durationMs, toolCalls, tokens: { input, output }, error? }`. `status` is `running`, `done`, `failed` (with `error`, the same text as the result) or `cancelled`; `conversationId` is the child conversation, `toolCalls` counts the tool calls in the child's assistant messages and `tokens` sums the child's `UsageDoc` (input includes cache reads and writes). While the child runs, the tool publishes `running` values through Pi's `api.details`, so they appear in the call's `LiveDoc` tool slot: it watches the child's `LiveDoc` (`api.watchDoc`) and, when a step of the child's run changes, recounts (`tx.scanEntries` from the last counted entry, `api.snapshot(UsageDoc)`) and publishes only when the counts change; Pi paces the commits. The final result carries `done`, `failed` or `cancelled`. When the delegate call itself is stopped, Pi writes its aborted result from the slot, so its details keep the last `running` value: read `running` on a settled error result as `cancelled`. The `pi-chat` block's `SubagentCard` and `parseDelegateDetails(details, result?)` do this and render any call carrying these details.
372
383
 
384
+ **Delegate limits, meter and child tools.** Host policy, all optional in `DelegateOptions` (or a `delegates` entry); Pi has no extension point for running-child limits, start rates or a host meter, so the tool enforces them itself (BORING-PI-4: host concern). With none set the tool behaves as above.
385
+
386
+ | Option | Type | Effect |
387
+ | --- | --- | --- |
388
+ | `limits.maxRunningPerConversation` | `number` | Children running at once for one parent conversation. |
389
+ | `limits.maxRunning` | `number` | Children running at once across every parent conversation, for the delegate tools sharing this `limits` object in this process. |
390
+ | `limits.maxStartsPerHour` | `number` | Children started for one parent conversation in the last 60 minutes (rolling), ended or not. |
391
+ | `limits.maxTaskChars` | `number` | Characters of `task`. |
392
+ | `limits.timeoutMs` | `number` | Wall-clock milliseconds from the call's first start (kept across a rerun). On expiry the tool aborts the child conversation (`ConversationHandle.abort`, the same cancel path as a stopped call) and the call fails: `details.status` `failed`, `error` `The <agent> agent timed out after <ms> ms.` |
393
+ | `meter` | `(parent, child) => DelegateMeter \| Promise<DelegateMeter>` | The host's handle for the child's spend: the tool calls `record(event)` once per child model response (`DelegateModelEvent`: `entryId`, `provider`, `model`, Pi's per-response `usage`) and `close(end)` once at the end (`done`, `failed`, `cancelled` or `timeout`). The kit keeps no log. |
394
+ | `childTools` | `string[]` | Names of the tools a child may have: its enabled tools are the intersection with what it would otherwise get, written as Pi's own `pi.agent` `tools` filter in the creating commit. |
395
+
396
+ A call over a limit is refused: it creates no child and returns an ordinary error result the model reads, for example `2 delegates already running for this conversation (limit 2).`, `1 delegate already running on this host (limit 1).`, `1 delegate already started for this conversation in the last hour (limit 1).` or `The task is 41 characters; the limit is 40.` It never throws and carries no `details`. Only a call that would create a child is checked: a rerun that finds its existing child (after a crash or eviction) is never refused, and a call counts once, by its tool task.
397
+
398
+ The counts live in this process and belong to the `limits` object: every delegate tool given the same object shares them, so one object passed to several `delegates` entries is one host-wide cap. Separate processes do not share them, and a restart starts them empty (reruns that reattach count their children again; the hourly window forgets earlier starts). `meter` is called after the commit that creates the child and again by a rerun in a new process that reattaches to it, since the earlier handle is gone; such a rerun sends the child's earlier responses again, so dedupe by `entryId`. A throw from `meter` fails the call; a throw from `record` or `close` is ignored.
399
+
400
+ ```ts
401
+ const limits = { maxRunningPerConversation: 2, maxRunning: 8, maxStartsPerHour: 20, maxTaskChars: 4000, timeoutMs: 300_000 };
402
+ const delegates = [
403
+ { agent: researcher, limits, childTools: ['search', 'read_file'], meter: (parent, child) => flightLog.open({ parent, child }) },
404
+ { agent: writer, limits }, // same object: shares the host-wide cap of 8
405
+ ];
406
+ ```
407
+
373
408
  **Schedule.** `@hachej/boring-agent/schedule` exports `createScheduleExtension({ now?, onDeadline?, maxDelaySeconds? })`, one native extension with `schedule({ instruction, delaySeconds | at, operationId? })`, `list_schedules()` and `cancel_schedule({ id })`, plus the delivery task `ScheduledDelivery`. A schedule is a native Pi task owned by the conversation as background work: it sleeps on the Harness clock (`runtime.sleep`) and then submits the instruction to its conversation as a follow-up input (entry kind `boring.schedule.delivery`, request id = schedule id, so a replayed delivery never submits twice). Pi's scheduler runs it and a restart resumes it; there is no second scheduler. Pending schedules live in the conversation document `ConversationSchedules`, so a conversation lists and cancels only its own; a cancelled schedule's task wakes and ends without delivering. `schedule` is idempotent on `operationId` (default: the tool task, so a replayed call returns the schedule it made). Hosts schedule outside a tool with `addSchedule(tx, conversationId, { operationId, instruction, runAt })`, `cancelSchedule(tx, conversationId, id)` and `listSchedules(harness, conversationId, context)`. Pass the Harness's own clock as `now`. A host with one wake-up timer (a Durable Object alarm) computes the earliest deadline from `harness.inspect()` with `scheduledDeadline(task)` and moves the timer earlier from `onDeadline`; its alarm handler only reopens or resumes the Harness, and Pi delivers what is due.
374
409
 
375
410
  **Storage conformance.** Boring ships no Pi `Storage` implementation. `test/packages/pi-storage-conformance.test.mjs` runs Pi's own suite (`createStorageConformance`, what `registerStorageConformance` registers with Vitest) on every storage the repository configures: `openPiStorage` (shared/pi-storage.mjs) on the local-disk and network-file-system presets, Pi's `SqliteStorage` over `openBrowserSqlite`, and Pi's `openDurableObjectSqliteStorage` on a SQLite-backed Durable Object in workerd through miniflare (the version the pinned wrangler resolves).
package/dist/agents.d.ts CHANGED
@@ -5,12 +5,12 @@ import type { SelfEvolutionReport } from './self-evolving.js';
5
5
  import type { DelegateOptions } from './delegate.js';
6
6
  import type { Skill } from './skills.js';
7
7
  export type { SelfEvolutionError, SelfEvolutionReport } from './self-evolving.js';
8
- export { createSkillsExtension, parseSkill } from './skills.js';
9
- export type { Skill } from './skills.js';
8
+ export { createSkillsExtension, directorySkillSource, fileSkillSource, parseSkill } from './skills.js';
9
+ export type { Skill, SkillError, SkillFileSystem, SkillListing, SkillSource, SkillsExtensionOptions } from './skills.js';
10
10
  export { jsonSchemaTool } from './json-schema-tool.js';
11
11
  export type { JsonObjectSchema, JsonSchemaToolOptions } from './json-schema-tool.js';
12
12
  export { createDelegateTool, DELEGATE_DETAILS_KIND, DELEGATE_TOOL } from './delegate.js';
13
- export type { DelegateDetails, DelegateOptions, DelegateStatus } from './delegate.js';
13
+ export type { DelegateDetails, DelegateEnd, DelegateLimits, DelegateMeter, DelegateModelEvent, DelegateOptions, DelegateStatus } from './delegate.js';
14
14
  /** One agent as plain data over native Pi pieces. It is a convenience, not another registry or lifecycle. */
15
15
  export interface AgentSpec {
16
16
  /** Stable name; it becomes the native extension name `agent.<id>`. */
package/dist/agents.js CHANGED
@@ -3,7 +3,7 @@ import { AgentDoc, defineExtension } from '@earendil-works/pi-durable';
3
3
  import { createSelfEvolution } from './self-evolving.js';
4
4
  import { NAME, createSkillsExtension, parseSkill, validSkill } from './skills.js';
5
5
  import { createDelegateTool } from './delegate.js';
6
- export { createSkillsExtension, parseSkill } from './skills.js';
6
+ export { createSkillsExtension, directorySkillSource, fileSkillSource, parseSkill } from './skills.js';
7
7
  export { jsonSchemaTool } from './json-schema-tool.js';
8
8
  export { createDelegateTool, DELEGATE_DETAILS_KIND, DELEGATE_TOOL } from './delegate.js';
9
9
  /** Turn an agent described as data into native extensions and a native agent configuration. */
@@ -1,4 +1,5 @@
1
- import type { AgentChange, ConversationId, ToolRegistration, Tx } from '@earendil-works/pi-durable';
1
+ import type { AgentChange, ConversationId, EntryId, ToolRegistration, Tx } from '@earendil-works/pi-durable';
2
+ import type { Usage } from '@earendil-works/pi-ai';
2
3
  export declare const DELEGATE_TOOL = "delegate";
3
4
  /** `DelegateDetails.kind`: how a UI recognises a sub-agent call's details, whatever the tool is named. */
4
5
  export declare const DELEGATE_DETAILS_KIND = "boring.delegate";
@@ -31,6 +32,50 @@ export type DelegateDetails = {
31
32
  /** Why the child failed (`failed` only). */
32
33
  readonly error?: string;
33
34
  };
35
+ /**
36
+ * Host policy for delegate calls (host-owned, BORING-PI-4: Pi has no extension point for running-child limits, start rates, task
37
+ * size or a child deadline, so the tool enforces them). Every field is optional; an unset field is no limit. A call that hits a
38
+ * limit creates no child and returns an error result whose text says why (never a throw), for example
39
+ * `2 delegates already running for this conversation (limit 2).`. Only a call that would create a child is checked: a rerun that
40
+ * reattaches to its existing child (after a crash or eviction) is never refused and is counted once.
41
+ *
42
+ * The counts live in this process and belong to this object: every delegate tool given the same `limits` object shares them
43
+ * (pass one object to several `delegates` entries for one host-wide cap). A process restart starts them empty; reruns that
44
+ * reattach to their children count those again, and the hourly window forgets the starts before the restart.
45
+ */
46
+ export interface DelegateLimits {
47
+ /** Children running at once for one parent conversation. */
48
+ readonly maxRunningPerConversation?: number;
49
+ /** Children started for one parent conversation in the last 60 minutes (a rolling window), whether or not they ended. */
50
+ readonly maxStartsPerHour?: number;
51
+ /** Children running at once across every parent conversation, for the tools sharing this object in this process. */
52
+ readonly maxRunning?: number;
53
+ /** Characters of `task`. */
54
+ readonly maxTaskChars?: number;
55
+ /**
56
+ * Wall-clock milliseconds a child may run, from the call's first start (kept across a rerun). On expiry the tool aborts the
57
+ * child conversation (Pi's `ConversationHandle.abort`) and the call fails with `The <agent> agent timed out after <ms> ms.`
58
+ */
59
+ readonly timeoutMs?: number;
60
+ }
61
+ /** How a metered child ended: `timeout` is a child `DelegateLimits.timeoutMs` cancelled. */
62
+ export type DelegateEnd = 'done' | 'failed' | 'cancelled' | 'timeout';
63
+ /** One response of the child's model, from its committed assistant entry. */
64
+ export type DelegateModelEvent = {
65
+ /** The child's entry; a rerun after a crash sends the entries again, so a host dedupes by `entryId`. */
66
+ readonly entryId: EntryId;
67
+ readonly provider: string;
68
+ readonly model: string;
69
+ /** Pi's per-response `Usage` (tokens and cost), as recorded on the assistant message. */
70
+ readonly usage: Usage;
71
+ };
72
+ /** The host's handle for one child's spend (its own flight log); the kit only feeds and closes it. */
73
+ export interface DelegateMeter {
74
+ /** One child model response, in entry order. A throw is ignored: the host's log owns its failures, not the child. */
75
+ readonly record: (event: DelegateModelEvent) => void | Promise<void>;
76
+ /** Called once, after the last `record`, when the call ends. A throw is ignored. */
77
+ readonly close: (end: DelegateEnd) => void | Promise<void>;
78
+ }
34
79
  export interface DelegateOptions {
35
80
  /**
36
81
  * The child agent, usually a `defineAgent` result (its `id` and native `agent` change). The child conversation selects exactly its
@@ -49,6 +94,20 @@ export interface DelegateOptions {
49
94
  * It runs once per child (never on a replay that finds the existing child); a throw aborts the commit, so no child exists.
50
95
  */
51
96
  readonly onChild?: (parent: ConversationId, child: ConversationId, tx: Tx) => void | Promise<void>;
97
+ /** Concurrency, rate, task size and timeout limits (`DelegateLimits`). */
98
+ readonly limits?: DelegateLimits;
99
+ /**
100
+ * Host metering (host-owned, BORING-PI-4: Pi records spend in `pi.usage` but has no hook to a host's log). Called after the
101
+ * commit that creates the child, and again by a rerun in a new process that reattaches to it (the earlier handle is gone).
102
+ * The tool sends the handle each child model response (`DelegateModelEvent`) and closes it when the call ends. A throw fails
103
+ * the call. The kit keeps no log.
104
+ */
105
+ readonly meter?: (parent: ConversationId, child: ConversationId) => DelegateMeter | Promise<DelegateMeter>;
106
+ /**
107
+ * Names of the tools a child may have: its enabled tools become the intersection of this list with what it would otherwise
108
+ * get, written as Pi's own tool filter (`pi.agent` `tools`) in the commit that creates it. Unset: no restriction.
109
+ */
110
+ readonly childTools?: readonly string[];
52
111
  }
53
112
  /**
54
113
  * Pi's subagent pattern (pi-durable README "Abort and Subagents") as a tool factory: the child is a conversation owned by the
@@ -58,6 +117,10 @@ export interface DelegateOptions {
58
117
  * child's final text. The child inherits the parent's approval and sandbox extensions (appended after its own, in the parent's order, so a
59
118
  * sandbox stays last), so a delegate cannot escape the parent's gate; `details` is a `DelegateDetails` (live while the child runs) for a UI that shows it.
60
119
  * `onChild` lets the host write the parent's identity onto the child in the same commit that creates it.
120
+ * Host policy, all optional (Pi has no extension point for these): `limits` (running children per conversation and per host,
121
+ * starts per hour, task size, a child deadline; a refusal is an error result, never a throw, and creates no child), `meter`
122
+ * (the host's flight-log handle, fed each child model response and closed when the call ends) and `childTools` (an allow list
123
+ * applied through Pi's own tool filter). With none of them set the tool behaves as before.
61
124
  */
62
125
  export declare function createDelegateTool(options: DelegateOptions): ToolRegistration;
63
126
  //# sourceMappingURL=delegate.d.ts.map
package/dist/delegate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AssistantEntry, LiveDoc, UsageDoc, configure, defineTool } from '@earendil-works/pi-durable';
1
+ import { AgentDoc, AssistantEntry, LiveDoc, UsageDoc, configure, defineTool } from '@earendil-works/pi-durable';
2
2
  import { Type } from '@earendil-works/pi-ai';
3
3
  const GUARDS = (globalThis[Symbol.for('boring.agent.guards')] ??= new WeakSet()); // approval + sandbox extensions: a delegated child inherits them
4
4
  const isGuard = (extension) => GUARDS.has(extension);
@@ -13,12 +13,17 @@ export const DELEGATE_DETAILS_KIND = 'boring.delegate';
13
13
  * child's final text. The child inherits the parent's approval and sandbox extensions (appended after its own, in the parent's order, so a
14
14
  * sandbox stays last), so a delegate cannot escape the parent's gate; `details` is a `DelegateDetails` (live while the child runs) for a UI that shows it.
15
15
  * `onChild` lets the host write the parent's identity onto the child in the same commit that creates it.
16
+ * Host policy, all optional (Pi has no extension point for these): `limits` (running children per conversation and per host,
17
+ * starts per hour, task size, a child deadline; a refusal is an error result, never a throw, and creates no child), `meter`
18
+ * (the host's flight-log handle, fed each child model response and closed when the call ends) and `childTools` (an allow list
19
+ * applied through Pi's own tool filter). With none of them set the tool behaves as before.
16
20
  */
17
21
  export function createDelegateTool(options) {
18
22
  const name = options.name ?? DELEGATE_TOOL;
19
23
  // A task-owned conversation starts as a copy of its owner's agent; reset the inherited filters so the child's own extensions are
20
24
  // its whole toolset, then apply the child agent. `cwd` stays inherited unless the child agent sets one.
21
25
  const child = { tools: null, modelTools: null, instructions: null, thinkingLevel: null, ...options.agent.agent };
26
+ const { limits } = options;
22
27
  return defineTool({
23
28
  name,
24
29
  description: options.description ?? `Delegate a self-contained task to the ${options.agent.id} agent and get its final answer back.`,
@@ -27,41 +32,82 @@ export function createDelegateTool(options) {
27
32
  execute: async (args, api, context) => {
28
33
  const inherited = (await api.agent(context)).extensions.filter(isGuard);
29
34
  const agent = withGuards(child, inherited);
30
- const childId = await api.commit(async (tx) => {
31
- const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
32
- if (existing !== undefined)
33
- return existing.id;
34
- const created = await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } });
35
- await configure(tx, created.id, agent);
36
- await options.onChild?.(api.conversationId, created.id, tx);
37
- return created.id;
38
- }, context);
39
- const startedAt = await api.memo('delegate.startedAt', Date.now(), context);
40
- const progress = childProgress(api, childId);
41
- const details = (status, counts, error) => ({
42
- kind: DELEGATE_DETAILS_KIND, version: 1, agent: options.agent.id, conversationId: childId, status, startedAt,
43
- durationMs: Math.max(1, Date.now() - startedAt), ...counts, ...(error === undefined ? {} : { error })
44
- });
45
- const initial = await progress.read(context);
46
- await api.details(details('running', initial), context);
47
- const conversation = await api.conversation(childId, context);
48
- if (conversation === undefined)
49
- throw new Error(`Delegate conversation ${childId} is missing`);
50
- const submission = await conversation.submit({ type: 'input', content: args.task, requestId: `${name}:${api.taskId}` }, context);
51
- const live = await publishLive(api, childId, progress, initial, counts => api.details(details('running', counts), context), context);
52
- // A stopped call ends here: Pi writes its aborted result from the slot, so its details stay the last `running` value.
53
- const settled = await submission.wait(context).finally(() => live.stop());
54
- const counts = await progress.read(context);
55
- if (settled.status !== 'done' || settled.type !== 'input') {
56
- const reason = settled.status === 'unanswered' ? settled.reason : settled.status;
57
- const detail = settled.status === 'unanswered' && typeof settled.detail === 'string' && settled.detail ? settled.detail : undefined;
58
- const error = `The ${options.agent.id} agent did not answer: ${reason}${detail ? ` (${detail})` : ''}`;
59
- return { output: [{ type: 'text', text: error }], isError: true, details: reason === 'aborted' ? details('cancelled', counts) : details('failed', counts, error) };
35
+ const parent = api.conversationId, ledger = limits === undefined ? undefined : ledgerOf(limits);
36
+ let opened;
37
+ try {
38
+ opened = await api.commit(async (tx) => {
39
+ const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
40
+ if (existing !== undefined) {
41
+ ledger?.running.set(api.taskId, parent);
42
+ return { id: existing.id };
43
+ }
44
+ const refused = ledger === undefined ? undefined : admit(limits, ledger, api.taskId, parent, args.task);
45
+ if (refused !== undefined)
46
+ return { refused };
47
+ const created = await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } });
48
+ await configure(tx, created.id, agent);
49
+ if (options.childTools !== undefined)
50
+ await allowTools(tx, created.id, options.childTools, api);
51
+ await options.onChild?.(parent, created.id, tx);
52
+ return { id: created.id };
53
+ }, context);
54
+ }
55
+ catch (error) {
56
+ ledger?.running.delete(api.taskId);
57
+ ledger?.starts.delete(api.taskId);
58
+ throw error;
59
+ }
60
+ if ('refused' in opened)
61
+ return { output: [{ type: 'text', text: opened.refused }], isError: true };
62
+ const childId = opened.id;
63
+ let meter, end = 'cancelled', timer;
64
+ try {
65
+ meter = await options.meter?.(parent, childId);
66
+ const startedAt = await api.memo('delegate.startedAt', Date.now(), context);
67
+ const progress = childProgress(api, childId, meter);
68
+ const details = (status, counts, error) => ({
69
+ kind: DELEGATE_DETAILS_KIND, version: 1, agent: options.agent.id, conversationId: childId, status, startedAt,
70
+ durationMs: Math.max(1, Date.now() - startedAt), ...counts, ...(error === undefined ? {} : { error })
71
+ });
72
+ const initial = await progress.read(context);
73
+ await api.details(details('running', initial), context);
74
+ const conversation = await api.conversation(childId, context);
75
+ if (conversation === undefined)
76
+ throw new Error(`Delegate conversation ${childId} is missing`);
77
+ const submission = await conversation.submit({ type: 'input', content: args.task, requestId: `${name}:${api.taskId}` }, context);
78
+ // The timeout reuses the cancel path: aborting the child settles its submission `aborted`, reported below as a timeout.
79
+ let timedOut = false;
80
+ if (limits?.timeoutMs !== undefined)
81
+ timer = setTimeout(() => { timedOut = true; conversation.abort(context).catch(() => undefined); }, Math.max(0, startedAt + limits.timeoutMs - Date.now()));
82
+ const live = await publishLive(api, childId, progress, initial, counts => api.details(details('running', counts), context), context);
83
+ // A stopped call ends here: Pi writes its aborted result from the slot, so its details stay the last `running` value.
84
+ const settled = await submission.wait(context).finally(() => live.stop());
85
+ clearTimeout(timer);
86
+ const counts = await progress.read(context);
87
+ if (settled.status !== 'done' || settled.type !== 'input') {
88
+ if (timedOut) {
89
+ end = 'timeout';
90
+ const error = `The ${options.agent.id} agent timed out after ${limits.timeoutMs} ms.`;
91
+ return { output: [{ type: 'text', text: error }], isError: true, details: details('failed', counts, error) };
92
+ }
93
+ const reason = settled.status === 'unanswered' ? settled.reason : settled.status;
94
+ const detail = settled.status === 'unanswered' && typeof settled.detail === 'string' && settled.detail ? settled.detail : undefined;
95
+ const error = `The ${options.agent.id} agent did not answer: ${reason}${detail ? ` (${detail})` : ''}`;
96
+ end = reason === 'aborted' ? 'cancelled' : 'failed';
97
+ return { output: [{ type: 'text', text: error }], isError: true, details: reason === 'aborted' ? details('cancelled', counts) : details('failed', counts, error) };
98
+ }
99
+ end = 'done';
100
+ const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), context);
101
+ const message = answer?.model?.find(part => part.role === 'assistant');
102
+ const text = message?.role === 'assistant' ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\n') : '';
103
+ return { output: [{ type: 'text', text }], details: details('done', counts) };
104
+ }
105
+ finally {
106
+ clearTimeout(timer);
107
+ ledger?.running.delete(api.taskId);
108
+ if (meter !== undefined)
109
+ await quietly(() => meter.close(end));
60
110
  }
61
- const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), context);
62
- const message = answer?.model?.find(part => part.role === 'assistant');
63
- const text = message?.role === 'assistant' ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\n') : '';
64
- return { output: [{ type: 'text', text }], details: details('done', counts) };
65
111
  },
66
112
  });
67
113
  }
@@ -69,26 +115,33 @@ export function createDelegateTool(options) {
69
115
  * The child's counts from its native records: tool calls from its committed assistant entries (scanned once, incrementally,
70
116
  * through the invocation's commit), tokens from its `pi.usage` (Pi's `UsageDoc`, both buckets).
71
117
  */
72
- function childProgress(api, childId) {
118
+ function childProgress(api, childId, meter) {
73
119
  let toolCalls = 0, after;
74
120
  return {
75
121
  async read(context) {
76
122
  const scanned = await api.commit(async (tx) => {
77
123
  let found = 0, last = after, cursor;
124
+ const events = [];
78
125
  do {
79
126
  const page = await tx.scanEntries({ conversationId: childId, order: 'ascending', ...(last === undefined ? {} : { minEntryId: (last + 1) }) }, 100, cursor);
80
127
  for (const entry of page.items) {
81
128
  last = entry.id;
82
129
  for (const message of entry.model ?? [])
83
- if (message.role === 'assistant')
130
+ if (message.role === 'assistant') {
84
131
  found += message.content.filter(part => part.type === 'toolCall').length;
132
+ events.push({ entryId: entry.id, provider: message.provider, model: message.model, usage: message.usage });
133
+ }
85
134
  }
86
135
  cursor = page.next;
87
136
  } while (cursor !== undefined);
88
- return { found, last };
137
+ return { found, last, events };
89
138
  }, context);
90
139
  toolCalls += scanned.found;
91
140
  after = scanned.last;
141
+ // Outside the commit, which may run more than once; reads are sequential, so events arrive once each, in entry order.
142
+ if (meter !== undefined)
143
+ for (const event of scanned.events)
144
+ await quietly(() => meter.record(event));
92
145
  const usage = await api.snapshot(UsageDoc, childId, context);
93
146
  const tokens = { input: 0, output: 0 };
94
147
  for (const bucket of [usage?.models, usage?.tools])
@@ -134,6 +187,55 @@ async function publishLive(api, childId, progress, initial, publish, context) {
134
187
  });
135
188
  return { async stop() { stopped = true; await watch.stop(); await reading; await committing; } };
136
189
  }
190
+ const quietly = async (run) => { try {
191
+ await run();
192
+ }
193
+ catch { /* the host's meter owns its failures */ } };
194
+ const LEDGERS = new WeakMap();
195
+ const ledgerOf = (limits) => {
196
+ let ledger = LEDGERS.get(limits);
197
+ if (ledger === undefined)
198
+ LEDGERS.set(limits, ledger = { running: new Map(), starts: new Map() });
199
+ return ledger;
200
+ };
201
+ const HOUR_MS = 60 * 60 * 1000;
202
+ const delegates = (count) => `${count} delegate${count === 1 ? '' : 's'}`;
203
+ /**
204
+ * Why a new child may not start, or `undefined` after reserving its running slot and its start. Synchronous, so two calls in
205
+ * this process cannot both pass the same last slot; idempotent for one task, since a commit may run its change again.
206
+ */
207
+ function admit(limits, ledger, taskId, parent, task) {
208
+ if (limits.maxTaskChars !== undefined && task.length > limits.maxTaskChars)
209
+ return `The task is ${task.length} characters; the limit is ${limits.maxTaskChars}.`;
210
+ const now = Date.now();
211
+ for (const [id, start] of ledger.starts)
212
+ if (now - start.at >= HOUR_MS)
213
+ ledger.starts.delete(id);
214
+ const others = [...ledger.running].filter(([id]) => id !== taskId);
215
+ const here = others.filter(([, owner]) => owner === parent).length;
216
+ if (limits.maxRunningPerConversation !== undefined && here >= limits.maxRunningPerConversation)
217
+ return `${delegates(here)} already running for this conversation (limit ${limits.maxRunningPerConversation}).`;
218
+ if (limits.maxRunning !== undefined && others.length >= limits.maxRunning)
219
+ return `${delegates(others.length)} already running on this host (limit ${limits.maxRunning}).`;
220
+ const started = [...ledger.starts].filter(([id, start]) => id !== taskId && start.parent === parent).length;
221
+ if (limits.maxStartsPerHour !== undefined && started >= limits.maxStartsPerHour)
222
+ return `${delegates(started)} already started for this conversation in the last hour (limit ${limits.maxStartsPerHour}).`;
223
+ ledger.running.set(taskId, parent);
224
+ ledger.starts.set(taskId, { parent, at: now });
225
+ return undefined;
226
+ }
227
+ /** Narrow the new child's tool filter (Pi's `pi.agent` `tools`, by name) to `allowed`: the intersection with what it would enable. */
228
+ async function allowTools(tx, childId, allowed, api) {
229
+ const state = await tx.doc(AgentDoc, childId);
230
+ const allow = new Set(allowed), current = state.tools;
231
+ if (Array.isArray(current)) {
232
+ state.tools = current.filter(tool => allow.has(tool));
233
+ return;
234
+ }
235
+ // Unset or `{ remove }`: every enabled tool (minus the removed), in registry order, kept to the allowed names.
236
+ const removed = new Set(current?.remove ?? []);
237
+ state.tools = [...new Set(api.registry.tools().map(({ tool }) => tool.name))].filter(tool => allow.has(tool) && !removed.has(tool));
238
+ }
137
239
  const runShape = (live) => JSON.stringify([live?.run?.taskId ?? null, live?.generation?.attempt ?? null, live?.generation?.message === undefined,
138
240
  (live?.tools ?? []).map(slot => `${slot.callId}:${slot.status}`)]);
139
241
  /** `change` with the parent's guard extensions appended to its extension selection (an array or `add`; an unset selection already inherits the parent's). */
package/dist/room.js CHANGED
@@ -287,19 +287,60 @@ export function createRoomTurn(options) {
287
287
  if (index >= 0)
288
288
  change(items, index);
289
289
  }, context);
290
- /** Relay the run's frames and each new `ask_user` while the wake is open. */
291
- async function follow(wakeId, from) {
290
+ /**
291
+ * Relay the run's frames and each new `ask_user` while the wake is open. A queued wake has no entry yet: its input
292
+ * lands when the earlier run is done, so follow from the start and learn `from` the first time its entry exists.
293
+ * Nothing is relayed before then, and never anything at or below the input's own entry (that is another wake's run).
294
+ * Text is relayed as growth: per entry the text length and step count already sent are remembered, only the rest goes.
295
+ * The partial reply of the run in flight lives in the `pi.live` document, not in the entries: when that run is this
296
+ * wake's (its inputs hold our submission), its text grows through the same path under `live`, and the settled entry
297
+ * is seeded from it so nothing is sent twice.
298
+ */
299
+ async function follow(wakeId, known) {
292
300
  const watch = await conversation.watch(context);
293
- const seen = new Set();
301
+ const sent = new Map();
302
+ let from = known;
303
+ let submission;
304
+ let live = '';
294
305
  let queue = Promise.resolve();
295
306
  // Port calls are chained off the watch so a slow room never holds native frames.
296
307
  const relay = async (view) => {
297
- const fresh = view.entries.filter(entry => entry.id > from && !seen.has(entry.id));
298
- for (const entry of fresh)
299
- seen.add(entry.id);
300
- const frames = framesOf(fresh);
301
- const questions = pendingQuestions(view.entries);
302
308
  queue = queue.then(async () => {
309
+ if (from === undefined || submission === undefined) {
310
+ const record = await conversation.commit(tx => tx.submissionByRequest(conversation.id, requestId(wakeId)), context);
311
+ submission = record?.id;
312
+ from ??= record?.entry;
313
+ }
314
+ if (from === undefined)
315
+ return;
316
+ const own = view.entries.filter(entry => entry.id > from);
317
+ const frames = [];
318
+ for (const entry of own) {
319
+ const all = framesOf([entry]);
320
+ const text = all.flatMap(frame => frame.type === 'text' ? [frame.text] : []).join('\n\n');
321
+ // The first settled text of this run starts after what its live partial already sent.
322
+ if (!sent.has(entry.id) && text) {
323
+ sent.set(entry.id, { text: text.startsWith(live) ? live.length : 0, steps: 0 });
324
+ live = '';
325
+ }
326
+ const before = sent.get(entry.id) ?? { text: 0, steps: 0 };
327
+ const steps = all.filter(frame => frame.type === 'step');
328
+ // Steps first: a tool call that began after the text belongs after it, but a lost order costs only polish.
329
+ frames.push(...steps.slice(before.steps));
330
+ if (text.length > before.text)
331
+ frames.push({ type: 'text', text: text.slice(before.text) });
332
+ sent.set(entry.id, { text: Math.max(before.text, text.length), steps: Math.max(before.steps, steps.length) });
333
+ }
334
+ const partial = view.docs['pi.live'];
335
+ const message = partial?.generation?.message;
336
+ if (submission !== undefined && partial?.run?.inputs?.includes(submission) && message?.role === 'assistant') {
337
+ const text = (message.content ?? []).flatMap(part => part.type === 'text' && part.text ? [part.text] : []).join('\n\n');
338
+ if (text.length > live.length && text.startsWith(live)) {
339
+ frames.push({ type: 'text', text: text.slice(live.length) });
340
+ live = text;
341
+ }
342
+ }
343
+ const questions = pendingQuestions(own);
303
344
  if (frames.length && port.stream)
304
345
  await port.stream(wakeId, frames);
305
346
  if (!port.question)
@@ -328,7 +369,8 @@ export function createRoomTurn(options) {
328
369
  // The same `requestId` returns the first submission: a resend or a restart never starts a second run.
329
370
  const submission = await conversation.submit({ type: 'input', requestId: requestId(wakeId), content: item.input, whenBusy: 'followUp' }, context);
330
371
  const placed = await conversation.commit(tx => tx.submissionByRequest(conversation.id, requestId(wakeId)), context);
331
- const stop = placed?.entry === undefined || placed.status === 'done' || placed.status === 'unanswered' ? async () => { } : await follow(wakeId, placed.entry);
372
+ // A queued submission has no entry yet; it is followed all the same (see `follow`).
373
+ const stop = placed?.status === 'done' || placed?.status === 'unanswered' ? async () => { } : await follow(wakeId, placed?.entry);
332
374
  let record;
333
375
  try {
334
376
  record = await submission.wait(context);
@@ -336,11 +378,13 @@ export function createRoomTurn(options) {
336
378
  finally {
337
379
  await stop();
338
380
  }
339
- // An unanswered run leaves the wake open: the room's resend cap closes it with its own line.
381
+ // An unanswered run is closed with the room's own line, never dropped: left open it would only wait for its resends.
340
382
  if (record.type === 'input' && record.status === 'done') {
341
383
  const entry = (await conversation.entries({ minEntryId: record.answer, maxEntryId: record.answer }, 1, undefined, context)).items[0];
342
384
  await port.settle(wakeId, { text: answerText(entry) });
343
385
  }
386
+ else if (record.type === 'input' && record.status === 'unanswered')
387
+ await port.settle(wakeId, { text: ROOM.unanswered });
344
388
  await update(wakeId, (items, index) => { items.splice(index, 1); });
345
389
  })().catch(report).finally(() => delivering.delete(wakeId));
346
390
  delivering.set(wakeId, run);
package/dist/skills.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Extension } from '@earendil-works/pi-durable';
2
+ import type { FileSystem } from '@earendil-works/pi-durable/env';
3
+ import type { Context } from '@earendil-works/chord';
2
4
  /** Names of skills and agents: a lowercase letter or digit, then lowercase letters, digits, `.`, `_` or `-` (at most 64). */
3
5
  export declare const NAME: RegExp;
4
6
  /** Instructions the agent loads on demand: only name and description occupy the system prompt. */
@@ -11,6 +13,41 @@ export declare function skillInFolder(skill: Skill, folder: string): Skill;
11
13
  /** Parse a `SKILL.md`-style document: `---` front matter with `name` and `description`, then the body. */
12
14
  export declare function parseSkill(markdown: string): Skill;
13
15
  export declare function validSkill(skill: Skill): Skill;
14
- /** A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`. */
15
- export declare function createSkillsExtension(name: string, skills: readonly Skill[]): Extension;
16
+ /** A skill a live source left out, and why. `name` is the folder or file name when the skill itself could not be read. */
17
+ export interface SkillError {
18
+ readonly path: string;
19
+ readonly name?: string;
20
+ readonly message: string;
21
+ }
22
+ /** What a source holds now: skills not yet validated, and the files it could not read. */
23
+ export interface SkillListing {
24
+ readonly skills: readonly Skill[];
25
+ readonly errors: readonly SkillError[];
26
+ }
27
+ /**
28
+ * Skills that can change while the agent runs. `version()` is a cheap token that changes when the skills change; `list()`
29
+ * reads them. The built-in sources read through the workspace's Pi `FileSystem`; a host may write its own.
30
+ */
31
+ export interface SkillSource {
32
+ readonly list: (context: Context) => Promise<SkillListing>;
33
+ readonly version: (context: Context) => Promise<string>;
34
+ }
35
+ /** The part of Pi's `FileSystem` the built-in sources read through (an `ExecutionEnv` is one). */
36
+ export type SkillFileSystem = Pick<FileSystem, 'readTextFile' | 'listDir' | 'fileInfo'>;
37
+ /** Every `<directory>/<name>/SKILL.md`; a missing directory holds no skills. Other files are companions and are never read. */
38
+ export declare function directorySkillSource(fs: SkillFileSystem, directory: string): SkillSource;
39
+ /** One skill file (`SKILL.md` front matter); a missing file holds no skill. Its folder is the file's directory. */
40
+ export declare function fileSkillSource(fs: SkillFileSystem, path: string): SkillSource;
41
+ export interface SkillsExtensionOptions {
42
+ /** How long a source's copy is used before `version()` is asked again, in milliseconds. Default 5000. Unused for an array. */
43
+ readonly maxAgeMs?: number;
44
+ /** Called after each read of a source with every skill it left out (an empty array when none). */
45
+ readonly onDiagnostics?: (errors: readonly SkillError[]) => void;
46
+ }
47
+ /**
48
+ * A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
49
+ * A fixed array is validated now: an invalid or duplicate skill throws. A `SkillSource` is read on first use and again, once
50
+ * the copy is older than `maxAgeMs`, when its version changed; what it cannot use is left out and reported, never thrown.
51
+ */
52
+ export declare function createSkillsExtension(name: string, skills: readonly Skill[] | SkillSource, options?: SkillsExtensionOptions): Extension;
16
53
  //# sourceMappingURL=skills.d.ts.map
package/dist/skills.js CHANGED
@@ -32,29 +32,165 @@ export function validSkill(skill) {
32
32
  const folder = skillFolders.get(skill);
33
33
  return folder === undefined ? valid : skillInFolder(valid, folder);
34
34
  }
35
- /** A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`. */
36
- export function createSkillsExtension(name, skills) {
37
- const known = new Map();
38
- for (const skill of skills.map(validSkill)) {
39
- if (known.has(skill.name))
40
- throw new TypeError(`Duplicate skill: ${skill.name}`);
35
+ const reason = (error) => error instanceof Error ? error.message : String(error);
36
+ const parent = (path) => { const at = path.lastIndexOf('/'); return at < 0 ? '.' : path.slice(0, at); };
37
+ const stamp = (info) => `${info.mtimeMs}:${info.size}`;
38
+ /** The file a built-in source read each skill from, so a skill left out later is reported at its file. */
39
+ const skillFiles = new WeakMap();
40
+ async function readSkillFile(fs, path, name, context) {
41
+ const text = await fs.readTextFile(path, context);
42
+ if (!text.ok)
43
+ return { path, name, message: text.error.message };
44
+ try {
45
+ const skill = skillInFolder(parseSkill(text.value), parent(path));
46
+ skillFiles.set(skill, path);
47
+ return skill;
48
+ }
49
+ catch (error) {
50
+ return { path, name, message: reason(error) };
51
+ }
52
+ }
53
+ /** Every `<directory>/<name>/SKILL.md`; a missing directory holds no skills. Other files are companions and are never read. */
54
+ export function directorySkillSource(fs, directory) {
55
+ const root = directory.replace(/\/+$/, '');
56
+ async function folders(context) {
57
+ const listed = await fs.listDir(root, context);
58
+ if (!listed.ok)
59
+ return listed.error.code === 'not_found' ? { names: [] } : { names: [], error: { path: root, message: listed.error.message } };
60
+ return { names: listed.value.filter(entry => entry.kind === 'directory').map(entry => entry.name).sort() };
61
+ }
62
+ const info = (name, context) => fs.fileInfo(`${root}/${name}/SKILL.md`, context);
63
+ return {
64
+ version: async (context) => {
65
+ const { names, error } = await folders(context);
66
+ if (error)
67
+ return `error:${error.message}`;
68
+ const stamps = await Promise.all(names.map(async (name) => {
69
+ const found = await info(name, context);
70
+ return `${name}=${found.ok ? stamp(found.value) : found.error.code}`;
71
+ }));
72
+ return stamps.join('|');
73
+ },
74
+ list: async (context) => {
75
+ const { names, error } = await folders(context);
76
+ const skills = [], errors = error ? [error] : [];
77
+ for (const name of names) {
78
+ const found = await info(name, context);
79
+ if (!found.ok && found.error.code === 'not_found')
80
+ continue;
81
+ const read = await readSkillFile(fs, `${root}/${name}/SKILL.md`, name, context);
82
+ if ('body' in read)
83
+ skills.push(read);
84
+ else
85
+ errors.push(read);
86
+ }
87
+ return { skills, errors };
88
+ },
89
+ };
90
+ }
91
+ /** One skill file (`SKILL.md` front matter); a missing file holds no skill. Its folder is the file's directory. */
92
+ export function fileSkillSource(fs, path) {
93
+ const name = path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/i, '');
94
+ return {
95
+ version: async (context) => {
96
+ const found = await fs.fileInfo(path, context);
97
+ return found.ok ? stamp(found.value) : found.error.code;
98
+ },
99
+ list: async (context) => {
100
+ const read = await readSkillFile(fs, path, name, context);
101
+ if ('body' in read)
102
+ return { skills: [read], errors: [] };
103
+ const found = await fs.fileInfo(path, context);
104
+ return !found.ok && found.error.code === 'not_found' ? { skills: [], errors: [] } : { skills: [], errors: [read] };
105
+ },
106
+ };
107
+ }
108
+ /** Validate what a source returned with `validSkill` and the duplicate rule: what fails is left out and reported, never thrown. */
109
+ function loaded(listing) {
110
+ const known = new Map(), errors = [...listing.errors];
111
+ for (const raw of listing.skills) {
112
+ const path = skillFiles.get(raw) ?? skillFolders.get(raw) ?? '';
113
+ let skill;
114
+ try {
115
+ skill = validSkill(raw);
116
+ }
117
+ catch (error) {
118
+ errors.push({ path, name: raw.name, message: reason(error) });
119
+ continue;
120
+ }
121
+ if (known.has(skill.name)) {
122
+ errors.push({ path, name: skill.name, message: `Duplicate skill: ${skill.name}` });
123
+ continue;
124
+ }
41
125
  known.set(skill.name, skill);
42
126
  }
43
- const listing = [...known.values()].map(skill => `- ${skill.name}: ${skill.description}`).join('\n');
127
+ return { known, errors };
128
+ }
129
+ /** The current copy of a source: read again once it is older than `maxAgeMs` and `version()` changed. Concurrent callers share one read. */
130
+ function liveSkills(source, options) {
131
+ const maxAgeMs = options.maxAgeMs ?? 5000;
132
+ let state = { known: new Map(), errors: [] };
133
+ let version, checkedAt = -Infinity, pending;
134
+ async function refresh(context) {
135
+ const next = await source.version(context);
136
+ if (next !== version) {
137
+ state = loaded(await source.list(context));
138
+ version = next;
139
+ options.onDiagnostics?.(state.errors);
140
+ }
141
+ checkedAt = Date.now();
142
+ return state;
143
+ }
144
+ return context => {
145
+ if (Date.now() - checkedAt < maxAgeMs)
146
+ return Promise.resolve(state);
147
+ return pending ??= refresh(context).finally(() => { pending = undefined; });
148
+ };
149
+ }
150
+ const INTRO = 'Before starting a task that matches a skill, call load_skill with its name and follow the returned instructions. A user message that starts with /<skill-name> naming one of these skills is an explicit request: call load_skill for that skill first and follow it for the rest of the message.';
151
+ /**
152
+ * A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
153
+ * A fixed array is validated now: an invalid or duplicate skill throws. A `SkillSource` is read on first use and again, once
154
+ * the copy is older than `maxAgeMs`, when its version changed; what it cannot use is left out and reported, never thrown.
155
+ */
156
+ export function createSkillsExtension(name, skills, options = {}) {
157
+ let current;
158
+ if (isSkillArray(skills)) {
159
+ const known = new Map();
160
+ for (const skill of skills.map(validSkill)) {
161
+ if (known.has(skill.name))
162
+ throw new TypeError(`Duplicate skill: ${skill.name}`);
163
+ known.set(skill.name, skill);
164
+ }
165
+ const fixed = { known, errors: [] };
166
+ current = async () => fixed;
167
+ }
168
+ else
169
+ current = liveSkills(skills, options);
44
170
  return defineExtension({
45
171
  name,
46
- sections: [{ key: 'skills', render: () => `Before starting a task that matches a skill, call load_skill with its name and follow the returned instructions. A user message that starts with /<skill-name> naming one of these skills is an explicit request: call load_skill for that skill first and follow it for the rest of the message.\n${listing}` }],
172
+ sections: [{ key: 'skills', render: async (_input, context) => {
173
+ const { known } = await current(context);
174
+ return known.size ? `${INTRO}\n${[...known.values()].map(skill => `- ${skill.name}: ${skill.description}`).join('\n')}` : undefined;
175
+ } }],
47
176
  tools: [defineTool({
48
177
  name: 'load_skill',
49
178
  description: 'Load the full instructions of one listed skill.',
50
179
  parameters: Type.Object({ name: Type.String() }, { additionalProperties: false }),
51
180
  replay: 'safe',
52
- execute: async (args) => {
181
+ execute: async (args, _api, context) => {
182
+ const { known, errors } = await current(context);
53
183
  const skill = known.get(args.name);
54
- const folder = skill && skillFolders.get(skill);
184
+ if (!skill) {
185
+ const failed = errors.find(error => error.name === args.name);
186
+ const text = failed ? `Skill "${args.name}" could not be loaded (${failed.path}): ${failed.message}` : `Unknown skill "${args.name}". Available: ${[...known.keys()].join(', ')}`;
187
+ return { output: [{ type: 'text', text }] };
188
+ }
189
+ const folder = skillFolders.get(skill);
55
190
  const location = folder === undefined ? '' : `\n\nThis skill's folder is \`${folder}\`; paths in it are relative to that folder.`;
56
- return { output: [{ type: 'text', text: skill ? skill.body + location : `Unknown skill "${args.name}". Available: ${[...known.keys()].join(', ')}` }] };
191
+ return { output: [{ type: 'text', text: skill.body + location }] };
57
192
  },
58
193
  })],
59
194
  });
60
195
  }
196
+ const isSkillArray = (skills) => Array.isArray(skills);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.4.20",
3
+ "version": "0.4.22",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -160,8 +160,8 @@
160
160
  "@earendil-works/pi-durable": "~1.2.0",
161
161
  "@earendil-works/pi-ai": "~1.2.0",
162
162
  "@earendil-works/chord": "~1.2.0",
163
- "@hachej/boring-files": "^0.4.20",
164
- "@hachej/boring-ui-kit": "^0.4.20",
163
+ "@hachej/boring-files": "^0.4.22",
164
+ "@hachej/boring-ui-kit": "^0.4.22",
165
165
  "xlsx": "^0.20.3"
166
166
  },
167
167
  "peerDependenciesMeta": {