@hachej/boring-agent 0.4.21 → 0.4.23
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 +58 -1
- package/dist/agents.d.ts +3 -3
- package/dist/agents.js +1 -1
- package/dist/delegate.d.ts +64 -1
- package/dist/delegate.js +140 -38
- package/dist/room.js +25 -3
- package/dist/skills.d.ts +77 -3
- package/dist/skills.js +296 -14
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -353,11 +353,44 @@ 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` are skipped, and companion files are read only with `companionFiles` (below). 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. `load_skill` asked for a name the conversation's listing does not hold makes the same check at once, whatever `maxAgeMs` (see *refresh on miss* below). 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
|
+
|
|
367
|
+
**Control (all optional; with none set, the extension behaves as above).**
|
|
368
|
+
|
|
369
|
+
- `visible(conversationId, skills)`: which skills a conversation sees. It is called once per render and once per `load_skill` call (may be async) with the whole current list of valid skills, and returns the subset that conversation sees (matched by name). The id is Pi's own: the section gets it from `PromptInput.conversationId`, the tool from `ToolExecutionApi.conversationId`, so no host bookkeeping is needed. A hidden skill is left out of the listing, and `load_skill` answers for it exactly as for an unknown name (`Unknown skill "<name>". Available: <visible names>`). With a filter, a skill that could not be read also answers as unknown (it is never in the list); its reason still reaches `onDiagnostics`. Works for an array too.
|
|
370
|
+
- `companionFiles: true`: `load_skill`'s answer ends with the files beside the skill's `SKILL.md` (the Agent Skills layout: `references/`, `scripts/`, `assets/`; any depth, name order, at most 200), and `load_skill({ name, path })` returns one of them as text. The files are checked when the listing is read, not when the model asks: each one must be a regular file inside the skill's folder that reads as UTF-8 text. A symbolic link, an unreadable or binary file (a NUL byte or invalid UTF-8) and files past the 200th are left out and reported to `onDiagnostics` (`{ path, name: <skill>, message: 'Companion file left out: …' }`). Only a listed path is read, so a path cannot leave the folder (`../`, absolute paths and links are refused with `Skill "<name>" has no companion file "<path>"`). The list is kept with the listing: a companion added or removed later shows up on the next read of the source (a `SKILL.md` change or `refresh()`); a listed file's content is read on demand through the source's `fs` (the same workspace `FileSystem`; both built-in sources set `SkillSource.fs`), never cached. A skill file not named `SKILL.md` (a `fileSkillSource` on `notes/triage.md`) has none. An array, or a source without `fs`, throws.
|
|
371
|
+
- `intro`: the listing section's text before the `- name: description` lines, replacing the default (“Before starting a task that matches a skill, call load_skill …”, with the `/<skill-name>` rule). The skill lines still follow it.
|
|
372
|
+
- `onPublish(next, previous)`: called with both listings' skills before a newly read listing of a source replaces the current one (may be async). Return a reason, or throw, to refuse it: the current listing stays and `onDiagnostics` gets `[{ path: '', message: 'Listing refused by onPublish: <reason>' }]`. A refused listing is not offered again until the source's version changes or `refresh()` runs. The first read is offered too (`previous` empty).
|
|
373
|
+
- `createSkillsController(name, source, options?)` returns `{ extension, refresh }`, so `createSkillsExtension` keeps its return type (it is `createSkillsController(...).extension` for a source). `await refresh(context)` reads the source now, whatever `maxAgeMs`, and publishes it through `onPublish`; `refresh(context, name)` takes only that skill from the new read (added, replaced or removed) and keeps the others as they were. It resolves `true` when published, `false` when refused. Reads run one at a time, so a refresh never interleaves with a `maxAgeMs` check. Call it from the code that changed the skills (an editor save, a self-evolution keep); there is still no watcher.
|
|
374
|
+
|
|
375
|
+
**Refresh on miss.** On a source, `load_skill` for a name not in the conversation's listing checks the source once before answering, whatever `maxAgeMs`: `version()` now, and `list()` plus `onPublish` only when the version changed, serialized with the other reads. So a skill written a moment ago loads on the first try, and the listing shows it from the next request on. A model guessing names costs one `version()` per miss (a `fileInfo` per skill folder for the built-in sources), never a re-read of an unchanged source, and concurrent misses share one check in flight, so there is no time floor. A miss of a refused listing's skill stays a miss until the version changes or `refresh()` runs, as above.
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
const skills = createSkillsController('workspace.skills', directorySkillSource(env, 'skills'), {
|
|
379
|
+
companionFiles: true,
|
|
380
|
+
visible: (conversationId, skills) => billingConversations.has(conversationId) ? skills : skills.filter(skill => !skill.name.startsWith('billing-')),
|
|
381
|
+
onDiagnostics: errors => errors.forEach(error => log.warn(`skill ${error.path}: ${error.message}`)),
|
|
382
|
+
intro: 'Clinic procedures. Load the matching one with load_skill before you answer:',
|
|
383
|
+
onPublish: next => next.some(skill => !skill.body.includes('Reviewed')) ? 'unreviewed skill' : undefined,
|
|
384
|
+
});
|
|
385
|
+
const agent = defineAgent({ id: 'clerk', model, extensions: [skills.extension] });
|
|
386
|
+
await skills.refresh(context, 'letters'); // after the host saved skills/letters/SKILL.md
|
|
387
|
+
```
|
|
388
|
+
|
|
356
389
|
## Sub-agents and schedules
|
|
357
390
|
|
|
358
391
|
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
392
|
|
|
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.
|
|
393
|
+
**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
394
|
|
|
362
395
|
**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
396
|
|
|
@@ -370,6 +403,30 @@ const delegates = [{ agent: researcher, onChild: async (parent, child, tx) => {
|
|
|
370
403
|
|
|
371
404
|
**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
405
|
|
|
406
|
+
**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.
|
|
407
|
+
|
|
408
|
+
| Option | Type | Effect |
|
|
409
|
+
| --- | --- | --- |
|
|
410
|
+
| `limits.maxRunningPerConversation` | `number` | Children running at once for one parent conversation. |
|
|
411
|
+
| `limits.maxRunning` | `number` | Children running at once across every parent conversation, for the delegate tools sharing this `limits` object in this process. |
|
|
412
|
+
| `limits.maxStartsPerHour` | `number` | Children started for one parent conversation in the last 60 minutes (rolling), ended or not. |
|
|
413
|
+
| `limits.maxTaskChars` | `number` | Characters of `task`. |
|
|
414
|
+
| `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.` |
|
|
415
|
+
| `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. |
|
|
416
|
+
| `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. |
|
|
417
|
+
|
|
418
|
+
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.
|
|
419
|
+
|
|
420
|
+
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.
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
const limits = { maxRunningPerConversation: 2, maxRunning: 8, maxStartsPerHour: 20, maxTaskChars: 4000, timeoutMs: 300_000 };
|
|
424
|
+
const delegates = [
|
|
425
|
+
{ agent: researcher, limits, childTools: ['search', 'read_file'], meter: (parent, child) => flightLog.open({ parent, child }) },
|
|
426
|
+
{ agent: writer, limits }, // same object: shares the host-wide cap of 8
|
|
427
|
+
];
|
|
428
|
+
```
|
|
429
|
+
|
|
373
430
|
**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
431
|
|
|
375
432
|
**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 { createSkillsController, createSkillsExtension, directorySkillSource, fileSkillSource, parseSkill } from './skills.js';
|
|
9
|
+
export type { Skill, SkillError, SkillFileSystem, SkillListing, SkillSource, SkillsController, 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 { createSkillsController, 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. */
|
package/dist/delegate.d.ts
CHANGED
|
@@ -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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
@@ -292,25 +292,38 @@ export function createRoomTurn(options) {
|
|
|
292
292
|
* lands when the earlier run is done, so follow from the start and learn `from` the first time its entry exists.
|
|
293
293
|
* Nothing is relayed before then, and never anything at or below the input's own entry (that is another wake's run).
|
|
294
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.
|
|
295
298
|
*/
|
|
296
299
|
async function follow(wakeId, known) {
|
|
297
300
|
const watch = await conversation.watch(context);
|
|
298
301
|
const sent = new Map();
|
|
299
302
|
let from = known;
|
|
303
|
+
let submission;
|
|
304
|
+
let live = '';
|
|
300
305
|
let queue = Promise.resolve();
|
|
301
306
|
// Port calls are chained off the watch so a slow room never holds native frames.
|
|
302
307
|
const relay = async (view) => {
|
|
303
308
|
queue = queue.then(async () => {
|
|
304
|
-
if (from === undefined)
|
|
305
|
-
|
|
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
|
+
}
|
|
306
314
|
if (from === undefined)
|
|
307
315
|
return;
|
|
308
316
|
const own = view.entries.filter(entry => entry.id > from);
|
|
309
317
|
const frames = [];
|
|
310
318
|
for (const entry of own) {
|
|
311
|
-
const before = sent.get(entry.id) ?? { text: 0, steps: 0 };
|
|
312
319
|
const all = framesOf([entry]);
|
|
313
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 };
|
|
314
327
|
const steps = all.filter(frame => frame.type === 'step');
|
|
315
328
|
// Steps first: a tool call that began after the text belongs after it, but a lost order costs only polish.
|
|
316
329
|
frames.push(...steps.slice(before.steps));
|
|
@@ -318,6 +331,15 @@ export function createRoomTurn(options) {
|
|
|
318
331
|
frames.push({ type: 'text', text: text.slice(before.text) });
|
|
319
332
|
sent.set(entry.id, { text: Math.max(before.text, text.length), steps: Math.max(before.steps, steps.length) });
|
|
320
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
|
+
}
|
|
321
343
|
const questions = pendingQuestions(own);
|
|
322
344
|
if (frames.length && port.stream)
|
|
323
345
|
await port.stream(wakeId, frames);
|
package/dist/skills.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { Extension } from '@earendil-works/pi-durable';
|
|
1
|
+
import type { ConversationId, 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,78 @@ 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
|
|
15
|
-
export
|
|
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
|
+
/** The FileSystem the skills' folders live in; the built-in sources set it. `companionFiles` reads through it. */
|
|
35
|
+
readonly fs?: SkillFileSystem;
|
|
36
|
+
}
|
|
37
|
+
/** The part of Pi's `FileSystem` the built-in sources read through (an `ExecutionEnv` is one). */
|
|
38
|
+
export type SkillFileSystem = Pick<FileSystem, 'readTextFile' | 'listDir' | 'fileInfo'>;
|
|
39
|
+
/** Every `<directory>/<name>/SKILL.md`; a missing directory holds no skills. Other files are read only with `companionFiles`. */
|
|
40
|
+
export declare function directorySkillSource(fs: SkillFileSystem, directory: string): SkillSource;
|
|
41
|
+
/** One skill file (`SKILL.md` front matter); a missing file holds no skill. Its folder is the file's directory. */
|
|
42
|
+
export declare function fileSkillSource(fs: SkillFileSystem, path: string): SkillSource;
|
|
43
|
+
export interface SkillsExtensionOptions {
|
|
44
|
+
/** How long a source's copy is used before `version()` is asked again, in milliseconds. Default 5000. Unused for an array. */
|
|
45
|
+
readonly maxAgeMs?: number;
|
|
46
|
+
/** Called after each read of a source with every skill it left out (an empty array when none), or with the `onPublish` refusal. */
|
|
47
|
+
readonly onDiagnostics?: (errors: readonly SkillError[]) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Which skills a conversation sees: called once per listing render and per `load_skill` call with Pi's conversation id and
|
|
50
|
+
* every valid skill, it returns the subset that conversation sees (matched by name; anything else returned is ignored).
|
|
51
|
+
* A hidden skill is left out of the listing and `load_skill` answers for it exactly as for an unknown name. With a filter,
|
|
52
|
+
* a skill that could not be read also answers as unknown (its reason still goes to `onDiagnostics`).
|
|
53
|
+
*/
|
|
54
|
+
readonly visible?: (conversationId: ConversationId, skills: readonly Skill[]) => readonly Skill[] | Promise<readonly Skill[]>;
|
|
55
|
+
/**
|
|
56
|
+
* List the files beside each `SKILL.md` (at any depth, at most 200) in `load_skill`'s answer, and let
|
|
57
|
+
* `load_skill({ name, path })` return one of them as text. Each file is checked when the listing is read: a symbolic link,
|
|
58
|
+
* a file that cannot be read or one that is not UTF-8 text is left out and reported to `onDiagnostics`. Read through the
|
|
59
|
+
* source's `fs`; an array or a source without `fs` throws.
|
|
60
|
+
*/
|
|
61
|
+
readonly companionFiles?: boolean;
|
|
62
|
+
/** The listing section's text before the `- name: description` lines. Default: call load_skill first, `/<skill-name>` is explicit. */
|
|
63
|
+
readonly intro?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Called before a newly read listing of a source replaces the current one, with both listings' skills. Return a reason
|
|
66
|
+
* (or throw) to refuse it: the current listing stays, and `onDiagnostics` gets `Listing refused by onPublish: <reason>`.
|
|
67
|
+
* A refused listing is offered again only once the source's version changes or `refresh()` is called.
|
|
68
|
+
*/
|
|
69
|
+
readonly onPublish?: (next: readonly Skill[], previous: readonly Skill[]) => string | void | Promise<string | void>;
|
|
70
|
+
}
|
|
71
|
+
/** A skills extension on a source, with a handle to re-read the source now. */
|
|
72
|
+
export interface SkillsController {
|
|
73
|
+
readonly extension: Extension;
|
|
74
|
+
/**
|
|
75
|
+
* Read the source now, whatever `maxAgeMs`, and publish what it holds (through `onPublish`). With `name`, only that skill
|
|
76
|
+
* is taken from the new read (added, replaced or removed); the others stay as they were. Resolves `true` when published,
|
|
77
|
+
* `false` when `onPublish` refused it.
|
|
78
|
+
*/
|
|
79
|
+
readonly refresh: (context: Context, name?: string) => Promise<boolean>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
|
|
83
|
+
* A fixed array is validated now: an invalid or duplicate skill throws. A `SkillSource` is read on first use and again, once
|
|
84
|
+
* the copy is older than `maxAgeMs`, when its version changed; what it cannot use is left out and reported, never thrown.
|
|
85
|
+
* `createSkillsController` returns the same extension on a source with a `refresh` handle.
|
|
86
|
+
*/
|
|
87
|
+
export declare function createSkillsExtension(name: string, skills: readonly Skill[] | SkillSource, options?: SkillsExtensionOptions): Extension;
|
|
88
|
+
/** `createSkillsExtension` on a source, plus `refresh(context, name?)` to re-read it now instead of after `maxAgeMs`. */
|
|
89
|
+
export declare function createSkillsController(name: string, source: SkillSource, options?: SkillsExtensionOptions): SkillsController;
|
|
16
90
|
//# sourceMappingURL=skills.d.ts.map
|
package/dist/skills.js
CHANGED
|
@@ -32,29 +32,311 @@ export function validSkill(skill) {
|
|
|
32
32
|
const folder = skillFolders.get(skill);
|
|
33
33
|
return folder === undefined ? valid : skillInFolder(valid, folder);
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 read only with `companionFiles`. */
|
|
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
|
+
fs,
|
|
65
|
+
version: async (context) => {
|
|
66
|
+
const { names, error } = await folders(context);
|
|
67
|
+
if (error)
|
|
68
|
+
return `error:${error.message}`;
|
|
69
|
+
const stamps = await Promise.all(names.map(async (name) => {
|
|
70
|
+
const found = await info(name, context);
|
|
71
|
+
return `${name}=${found.ok ? stamp(found.value) : found.error.code}`;
|
|
72
|
+
}));
|
|
73
|
+
return stamps.join('|');
|
|
74
|
+
},
|
|
75
|
+
list: async (context) => {
|
|
76
|
+
const { names, error } = await folders(context);
|
|
77
|
+
const skills = [], errors = error ? [error] : [];
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
const found = await info(name, context);
|
|
80
|
+
if (!found.ok && found.error.code === 'not_found')
|
|
81
|
+
continue;
|
|
82
|
+
const read = await readSkillFile(fs, `${root}/${name}/SKILL.md`, name, context);
|
|
83
|
+
if ('body' in read)
|
|
84
|
+
skills.push(read);
|
|
85
|
+
else
|
|
86
|
+
errors.push(read);
|
|
87
|
+
}
|
|
88
|
+
return { skills, errors };
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** One skill file (`SKILL.md` front matter); a missing file holds no skill. Its folder is the file's directory. */
|
|
93
|
+
export function fileSkillSource(fs, path) {
|
|
94
|
+
const name = path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/i, '');
|
|
95
|
+
return {
|
|
96
|
+
fs,
|
|
97
|
+
version: async (context) => {
|
|
98
|
+
const found = await fs.fileInfo(path, context);
|
|
99
|
+
return found.ok ? stamp(found.value) : found.error.code;
|
|
100
|
+
},
|
|
101
|
+
list: async (context) => {
|
|
102
|
+
const read = await readSkillFile(fs, path, name, context);
|
|
103
|
+
if ('body' in read)
|
|
104
|
+
return { skills: [read], errors: [] };
|
|
105
|
+
const found = await fs.fileInfo(path, context);
|
|
106
|
+
return !found.ok && found.error.code === 'not_found' ? { skills: [], errors: [] } : { skills: [], errors: [read] };
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Validate what a source returned with `validSkill` and the duplicate rule: what fails is left out and reported, never thrown. */
|
|
111
|
+
function loaded(listing) {
|
|
112
|
+
const known = new Map(), errors = [...listing.errors];
|
|
113
|
+
for (const raw of listing.skills) {
|
|
114
|
+
const path = skillFiles.get(raw) ?? skillFolders.get(raw) ?? '';
|
|
115
|
+
let skill;
|
|
116
|
+
try {
|
|
117
|
+
skill = validSkill(raw);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
errors.push({ path, name: raw.name, message: reason(error) });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (known.has(skill.name)) {
|
|
124
|
+
errors.push({ path, name: skill.name, message: `Duplicate skill: ${skill.name}` });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const file = skillFiles.get(raw);
|
|
128
|
+
if (file !== undefined)
|
|
129
|
+
skillFiles.set(skill, file);
|
|
41
130
|
known.set(skill.name, skill);
|
|
42
131
|
}
|
|
43
|
-
|
|
132
|
+
return { known, errors };
|
|
133
|
+
}
|
|
134
|
+
/** `next` with only `name` taken from it; the rest of `current` is kept. */
|
|
135
|
+
function only(name, next, current) {
|
|
136
|
+
const known = new Map(current.known), skill = next.known.get(name);
|
|
137
|
+
if (skill)
|
|
138
|
+
known.set(name, skill);
|
|
139
|
+
else
|
|
140
|
+
known.delete(name);
|
|
141
|
+
return { known, errors: [...current.errors.filter(error => error.name !== name), ...next.errors.filter(error => error.name === name)] };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The current copy of a source: read again once it is older than `maxAgeMs` and `version()` changed, or when `refresh` asks.
|
|
145
|
+
* Reads run one at a time; concurrent callers of `current` share one.
|
|
146
|
+
*/
|
|
147
|
+
function liveSkills(source, options) {
|
|
148
|
+
const maxAgeMs = options.maxAgeMs ?? 5000, fs = options.companionFiles ? source.fs : undefined;
|
|
149
|
+
let state = { known: new Map(), errors: [] };
|
|
150
|
+
let version, checkedAt = -Infinity, pending, missing;
|
|
151
|
+
let queue = Promise.resolve();
|
|
152
|
+
const read = async (context) => { const next = loaded(await source.list(context)); return fs ? withCompanions(fs, next, context) : next; };
|
|
153
|
+
const serial = (run) => { const next = queue.then(run, run); queue = next.catch(() => { }); return next; };
|
|
154
|
+
async function refusal(next) {
|
|
155
|
+
if (!options.onPublish)
|
|
156
|
+
return undefined;
|
|
157
|
+
try {
|
|
158
|
+
return (await options.onPublish([...next.known.values()], [...state.known.values()])) ?? undefined;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return reason(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function publish(next) {
|
|
165
|
+
const refused = await refusal(next);
|
|
166
|
+
if (refused !== undefined) {
|
|
167
|
+
options.onDiagnostics?.([{ path: '', message: `Listing refused by onPublish: ${refused}` }]);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
state = next;
|
|
171
|
+
options.onDiagnostics?.(state.errors);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
async function check(context) {
|
|
175
|
+
const next = await source.version(context);
|
|
176
|
+
if (next !== version) {
|
|
177
|
+
const listing = await read(context);
|
|
178
|
+
version = next;
|
|
179
|
+
await publish(listing);
|
|
180
|
+
}
|
|
181
|
+
checkedAt = Date.now();
|
|
182
|
+
return state;
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
current: (context) => {
|
|
186
|
+
if (Date.now() - checkedAt < maxAgeMs)
|
|
187
|
+
return Promise.resolve(state);
|
|
188
|
+
return pending ??= serial(() => check(context)).finally(() => { pending = undefined; });
|
|
189
|
+
},
|
|
190
|
+
// A load_skill miss: the maxAgeMs check now. One at a time, shared by concurrent misses; `list()` and `onPublish` run
|
|
191
|
+
// only when `version()` changed, so a model asking for names that do not exist costs one `version()` per miss.
|
|
192
|
+
miss: (context) => missing ??= serial(() => check(context)).finally(() => { missing = undefined; }),
|
|
193
|
+
refresh: (context, name) => serial(async () => {
|
|
194
|
+
const next = await source.version(context), listing = await read(context);
|
|
195
|
+
// One skill taken: the copy no longer matches any version, so the next check reads the whole source again.
|
|
196
|
+
version = name === undefined ? next : undefined;
|
|
197
|
+
checkedAt = Date.now();
|
|
198
|
+
return publish(name === undefined ? listing : only(name, listing, state));
|
|
199
|
+
}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const INTRO_TEXT = '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.';
|
|
203
|
+
const COMPANION_LIMIT = 200;
|
|
204
|
+
/** Each skill's companion files, checked when its listing was read. */
|
|
205
|
+
const companionLists = new WeakMap();
|
|
206
|
+
/**
|
|
207
|
+
* Walk the files beside each skill's `SKILL.md` (relative to its folder, depth first in name order, at most
|
|
208
|
+
* `COMPANION_LIMIT`) and keep those that read as UTF-8 text. A symbolic link, an unreadable or non-text file, and files past
|
|
209
|
+
* the limit are left out and reported with the skill's name. A skill file not named `SKILL.md` has none.
|
|
210
|
+
*/
|
|
211
|
+
async function withCompanions(fs, listing, context) {
|
|
212
|
+
const errors = [...listing.errors];
|
|
213
|
+
for (const skill of listing.known.values()) {
|
|
214
|
+
const folder = skillFolders.get(skill), file = skillFiles.get(skill), files = [];
|
|
215
|
+
companionLists.set(skill, files);
|
|
216
|
+
if (folder === undefined || (file !== undefined && !/(^|\/)SKILL\.md$/.test(file)))
|
|
217
|
+
continue;
|
|
218
|
+
const at = (relative) => relative === '' ? folder : folder === '.' ? relative : `${folder}/${relative}`;
|
|
219
|
+
const leftOut = (relative, message) => { errors.push({ path: at(relative), name: skill.name, message }); };
|
|
220
|
+
let over = false;
|
|
221
|
+
const walk = async (relative) => {
|
|
222
|
+
const listed = await fs.listDir(at(relative), context);
|
|
223
|
+
if (!listed.ok) {
|
|
224
|
+
leftOut(relative, `Companion files could not be listed: ${listed.error.message}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
for (const entry of [...listed.value].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
228
|
+
if (over)
|
|
229
|
+
return;
|
|
230
|
+
const path = relative === '' ? entry.name : `${relative}/${entry.name}`;
|
|
231
|
+
if (entry.kind === 'directory') {
|
|
232
|
+
await walk(path);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (path === 'SKILL.md')
|
|
236
|
+
continue;
|
|
237
|
+
if (entry.kind === 'symlink') {
|
|
238
|
+
leftOut(path, 'Companion file left out: a symbolic link');
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (files.length >= COMPANION_LIMIT) {
|
|
242
|
+
over = true;
|
|
243
|
+
leftOut('', `Companion files left out: more than ${COMPANION_LIMIT}`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const text = await fs.readTextFile(at(path), context);
|
|
247
|
+
if (!text.ok)
|
|
248
|
+
leftOut(path, `Companion file left out: ${text.error.message}`);
|
|
249
|
+
else if (/[\u0000\uFFFD]/.test(text.value))
|
|
250
|
+
leftOut(path, 'Companion file left out: not UTF-8 text');
|
|
251
|
+
else
|
|
252
|
+
files.push(path);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
await walk('');
|
|
256
|
+
}
|
|
257
|
+
return { known: listing.known, errors };
|
|
258
|
+
}
|
|
259
|
+
/** The listing section and `load_skill` over `live`, filtered per conversation by `visible`. */
|
|
260
|
+
function skillsExtension(name, live, options, fs) {
|
|
261
|
+
const { visible } = options, intro = options.intro ?? INTRO_TEXT;
|
|
262
|
+
// `at` is read only with a filter, so direct callers of render/execute without Pi's input or api keep working.
|
|
263
|
+
async function seen(known, at) {
|
|
264
|
+
const all = [...known.values()];
|
|
265
|
+
if (!visible)
|
|
266
|
+
return all;
|
|
267
|
+
const names = new Set((await visible(at(), all)).map(skill => skill.name));
|
|
268
|
+
return all.filter(skill => names.has(skill.name));
|
|
269
|
+
}
|
|
270
|
+
const parameters = fs
|
|
271
|
+
? Type.Object({ name: Type.String(), path: Type.Optional(Type.String({ description: 'A companion file of the skill, relative to its folder.' })) }, { additionalProperties: false })
|
|
272
|
+
: Type.Object({ name: Type.String() }, { additionalProperties: false });
|
|
44
273
|
return defineExtension({
|
|
45
274
|
name,
|
|
46
|
-
sections: [{ key: 'skills', render: () =>
|
|
275
|
+
sections: [{ key: 'skills', render: async (input, context) => {
|
|
276
|
+
const skills = await seen((await live.current(context)).known, () => input.conversationId);
|
|
277
|
+
return skills.length ? `${intro}\n${skills.map(skill => `- ${skill.name}: ${skill.description}`).join('\n')}` : undefined;
|
|
278
|
+
} }],
|
|
47
279
|
tools: [defineTool({
|
|
48
280
|
name: 'load_skill',
|
|
49
|
-
description: 'Load the full instructions of one listed skill.',
|
|
50
|
-
parameters
|
|
281
|
+
description: fs ? 'Load the full instructions of one listed skill, or with `path` one of its companion files.' : 'Load the full instructions of one listed skill.',
|
|
282
|
+
parameters,
|
|
51
283
|
replay: 'safe',
|
|
52
|
-
execute: async (args) => {
|
|
53
|
-
const
|
|
54
|
-
|
|
284
|
+
execute: async (args, api, context) => {
|
|
285
|
+
const text = (value) => ({ output: [{ type: 'text', text: value }] });
|
|
286
|
+
let { known, errors } = await live.current(context);
|
|
287
|
+
let shown = await seen(known, () => api.conversationId);
|
|
288
|
+
if (!shown.some(candidate => candidate.name === args.name) && live.miss) {
|
|
289
|
+
// Not in this conversation's listing: the skill may have been added since, so check the source once now.
|
|
290
|
+
({ known, errors } = await live.miss(context));
|
|
291
|
+
shown = await seen(known, () => api.conversationId);
|
|
292
|
+
}
|
|
293
|
+
const skill = shown.find(candidate => candidate.name === args.name);
|
|
294
|
+
if (!skill) {
|
|
295
|
+
const failed = visible ? undefined : errors.find(error => error.name === args.name);
|
|
296
|
+
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(', ')}`);
|
|
297
|
+
}
|
|
298
|
+
const folder = skillFolders.get(skill);
|
|
299
|
+
const companions = fs ? companionLists.get(skill) ?? [] : [];
|
|
300
|
+
if (fs && args.path !== undefined) {
|
|
301
|
+
// Only a listed file is read, so the path cannot leave the folder or follow a symbolic link.
|
|
302
|
+
const path = args.path.replace(/^(\.\/)+/, '');
|
|
303
|
+
if (!companions.includes(path))
|
|
304
|
+
return text(`Skill "${skill.name}" has no companion file "${args.path}". Companion files: ${companions.join(', ') || 'none'}`);
|
|
305
|
+
const read = await fs.readTextFile(folder === '.' ? path : `${folder}/${path}`, context);
|
|
306
|
+
return text(read.ok ? read.value : `Companion file "${path}" of skill "${skill.name}" could not be read: ${read.error.message}`);
|
|
307
|
+
}
|
|
55
308
|
const location = folder === undefined ? '' : `\n\nThis skill's folder is \`${folder}\`; paths in it are relative to that folder.`;
|
|
56
|
-
|
|
309
|
+
const listed = companions.length ? `\n\nCompanion files of this skill (read one with load_skill and its path):\n${companions.map(path => `- ${path}`).join('\n')}` : '';
|
|
310
|
+
return text(skill.body + location + listed);
|
|
57
311
|
},
|
|
58
312
|
})],
|
|
59
313
|
});
|
|
60
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
|
|
317
|
+
* A fixed array is validated now: an invalid or duplicate skill throws. A `SkillSource` is read on first use and again, once
|
|
318
|
+
* the copy is older than `maxAgeMs`, when its version changed; what it cannot use is left out and reported, never thrown.
|
|
319
|
+
* `createSkillsController` returns the same extension on a source with a `refresh` handle.
|
|
320
|
+
*/
|
|
321
|
+
export function createSkillsExtension(name, skills, options = {}) {
|
|
322
|
+
if (!isSkillArray(skills))
|
|
323
|
+
return createSkillsController(name, skills, options).extension;
|
|
324
|
+
if (options.companionFiles)
|
|
325
|
+
throw new TypeError('Skills extension: companionFiles need a SkillSource with fs, not an array');
|
|
326
|
+
const known = new Map();
|
|
327
|
+
for (const skill of skills.map(validSkill)) {
|
|
328
|
+
if (known.has(skill.name))
|
|
329
|
+
throw new TypeError(`Duplicate skill: ${skill.name}`);
|
|
330
|
+
known.set(skill.name, skill);
|
|
331
|
+
}
|
|
332
|
+
const fixed = { known, errors: [] };
|
|
333
|
+
return skillsExtension(name, { current: async () => fixed }, options, undefined);
|
|
334
|
+
}
|
|
335
|
+
/** `createSkillsExtension` on a source, plus `refresh(context, name?)` to re-read it now instead of after `maxAgeMs`. */
|
|
336
|
+
export function createSkillsController(name, source, options = {}) {
|
|
337
|
+
if (options.companionFiles && !source.fs)
|
|
338
|
+
throw new TypeError('Skills extension: companionFiles need a SkillSource with fs');
|
|
339
|
+
const live = liveSkills(source, options);
|
|
340
|
+
return { extension: skillsExtension(name, live, options, options.companionFiles ? source.fs : undefined), refresh: live.refresh };
|
|
341
|
+
}
|
|
342
|
+
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.
|
|
3
|
+
"version": "0.4.23",
|
|
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.
|
|
164
|
-
"@hachej/boring-ui-kit": "^0.4.
|
|
163
|
+
"@hachej/boring-files": "^0.4.23",
|
|
164
|
+
"@hachej/boring-ui-kit": "^0.4.23",
|
|
165
165
|
"xlsx": "^0.20.3"
|
|
166
166
|
},
|
|
167
167
|
"peerDependenciesMeta": {
|