@hachej/boring-agent 0.4.24 → 0.4.25

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
@@ -367,10 +367,10 @@ Pi renders every extension section before each model request (`PromptSection.ren
367
367
  **Control (all optional; with none set, the extension behaves as above).**
368
368
 
369
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.
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, and a listed file is checked again 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>"`). Before returning a listed file, `load_skill` looks at the skill's folder, every folder on the way and the file with `fileInfo` (which does not follow links) and checks the text again: a file or folder swapped for a symbolic link since the listing (the agent may write its own skill folder) answers `Companion file "<path>" of skill "<name>" could not be read: <path> is a symbolic link` and is never followed. 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
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.
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 that listing's errors followed by `{ path: '', message: 'Listing refused by onPublish: <reason>' }` (the per-skill errors are kept; the entry with `path: ''` is the refusal). 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 until the source's `version()` changes again: later `maxAgeMs` checks and `load_skill` misses do not re-read an unchanged source, and the first read after a change offers the whole source (through `onPublish`) as usual. A source only lists everything at once, so the others' pending edits are part of that next read. 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
374
 
375
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
376
 
@@ -412,12 +412,12 @@ const delegates = [{ agent: researcher, onChild: async (parent, child, tx) => {
412
412
  | `limits.maxStartsPerHour` | `number` | Children started for one parent conversation in the last 60 minutes (rolling), ended or not. |
413
413
  | `limits.maxTaskChars` | `number` | Characters of `task`. |
414
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. |
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` once the child's answer is committed to the parent; `cancelled` only when the call or the child was aborted; `timeout`; `failed` for every other end, including a throw such as a failed commit). The kit keeps no log. |
416
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
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.
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 across the conversations sharing this limit (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
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.
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, while two entries written with their own literals (`limits: { maxRunning: 2 }` each) get two separate budgets; the refusal text says “sharing this limit” for that reason. 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
421
 
422
422
  ```ts
423
423
  const limits = { maxRunningPerConversation: 2, maxRunning: 8, maxStartsPerHour: 20, maxTaskChars: 4000, timeoutMs: 300_000 };
@@ -117,7 +117,8 @@ export interface DelegateOptions {
117
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
118
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.
119
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,
120
+ * Host policy, all optional (Pi has no extension point for these): `limits` (running children per conversation and across
121
+ * the conversations sharing one `limits` object in this process,
121
122
  * starts per hour, task size, a child deadline; a refusal is an error result, never a throw, and creates no child), `meter`
122
123
  * (the host's flight-log handle, fed each child model response and closed when the call ends) and `childTools` (an allow list
123
124
  * applied through Pi's own tool filter). With none of them set the tool behaves as before.
package/dist/delegate.js CHANGED
@@ -13,7 +13,8 @@ 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,
16
+ * Host policy, all optional (Pi has no extension point for these): `limits` (running children per conversation and across
17
+ * the conversations sharing one `limits` object in this process,
17
18
  * starts per hour, task size, a child deadline; a refusal is an error result, never a throw, and creates no child), `meter`
18
19
  * (the host's flight-log handle, fed each child model response and closed when the call ends) and `childTools` (an allow list
19
20
  * applied through Pi's own tool filter). With none of them set the tool behaves as before.
@@ -60,7 +61,8 @@ export function createDelegateTool(options) {
60
61
  if ('refused' in opened)
61
62
  return { output: [{ type: 'text', text: opened.refused }], isError: true };
62
63
  const childId = opened.id;
63
- let meter, end = 'cancelled', timer;
64
+ // `end` is set where the call's outcome is known; a throw ends `cancelled` only when the call itself was aborted.
65
+ let meter, end = 'failed', timer;
64
66
  try {
65
67
  meter = await options.meter?.(parent, childId);
66
68
  const startedAt = await api.memo('delegate.startedAt', Date.now(), context);
@@ -96,12 +98,16 @@ export function createDelegateTool(options) {
96
98
  end = reason === 'aborted' ? 'cancelled' : 'failed';
97
99
  return { output: [{ type: 'text', text: error }], isError: true, details: reason === 'aborted' ? details('cancelled', counts) : details('failed', counts, error) };
98
100
  }
99
- end = 'done';
100
101
  const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), context);
102
+ end = 'done';
101
103
  const message = answer?.model?.find(part => part.role === 'assistant');
102
104
  const text = message?.role === 'assistant' ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\n') : '';
103
105
  return { output: [{ type: 'text', text }], details: details('done', counts) };
104
106
  }
107
+ catch (error) {
108
+ end = context.abortSignal?.aborted ? 'cancelled' : 'failed';
109
+ throw error;
110
+ }
105
111
  finally {
106
112
  clearTimeout(timer);
107
113
  ledger?.running.delete(api.taskId);
@@ -216,7 +222,7 @@ function admit(limits, ledger, taskId, parent, task) {
216
222
  if (limits.maxRunningPerConversation !== undefined && here >= limits.maxRunningPerConversation)
217
223
  return `${delegates(here)} already running for this conversation (limit ${limits.maxRunningPerConversation}).`;
218
224
  if (limits.maxRunning !== undefined && others.length >= limits.maxRunning)
219
- return `${delegates(others.length)} already running on this host (limit ${limits.maxRunning}).`;
225
+ return `${delegates(others.length)} already running across the conversations sharing this limit (limit ${limits.maxRunning}).`;
220
226
  const started = [...ledger.starts].filter(([id, start]) => id !== taskId && start.parent === parent).length;
221
227
  if (limits.maxStartsPerHour !== undefined && started >= limits.maxStartsPerHour)
222
228
  return `${delegates(started)} already started for this conversation in the last hour (limit ${limits.maxStartsPerHour}).`;
package/dist/skills.d.ts CHANGED
@@ -43,7 +43,10 @@ export declare function fileSkillSource(fs: SkillFileSystem, path: string): Skil
43
43
  export interface SkillsExtensionOptions {
44
44
  /** How long a source's copy is used before `version()` is asked again, in milliseconds. Default 5000. Unused for an array. */
45
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. */
46
+ /**
47
+ * Called after each read of a source with every skill it left out (an empty array when none). When `onPublish` refuses the
48
+ * read, it gets the errors of the listing still in effect followed by `{ path: '', message: 'Listing refused by onPublish: <reason>' }`.
49
+ */
47
50
  readonly onDiagnostics?: (errors: readonly SkillError[]) => void;
48
51
  /**
49
52
  * Which skills a conversation sees: called once per listing render and per `load_skill` call with Pi's conversation id and
@@ -55,7 +58,9 @@ export interface SkillsExtensionOptions {
55
58
  /**
56
59
  * List the files beside each `SKILL.md` (at any depth, at most 200) in `load_skill`'s answer, and let
57
60
  * `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
61
+ * a file that cannot be read or one that is not UTF-8 text is left out and reported to `onDiagnostics`. `load_skill` checks a
62
+ * listed file again before returning it (the skill's folder, every folder on the way and the file itself, with `fileInfo`,
63
+ * which does not follow links), so a file or folder swapped for a link since the listing is refused. Read through the
59
64
  * source's `fs`; an array or a source without `fs` throws.
60
65
  */
61
66
  readonly companionFiles?: boolean;
@@ -63,7 +68,7 @@ export interface SkillsExtensionOptions {
63
68
  readonly intro?: string;
64
69
  /**
65
70
  * 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>`.
71
+ * (or throw) to refuse it: the current listing stays, and `onDiagnostics` gets its errors plus `Listing refused by onPublish: <reason>`.
67
72
  * A refused listing is offered again only once the source's version changes or `refresh()` is called.
68
73
  */
69
74
  readonly onPublish?: (next: readonly Skill[], previous: readonly Skill[]) => string | void | Promise<string | void>;
@@ -73,8 +78,9 @@ export interface SkillsController {
73
78
  readonly extension: Extension;
74
79
  /**
75
80
  * 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.
81
+ * is taken from the new read (added, replaced or removed); the others stay as they were until the source's `version()`
82
+ * changes again, when the next read offers the whole source as usual. Resolves `true` when published, `false` when
83
+ * `onPublish` refused it.
78
84
  */
79
85
  readonly refresh: (context: Context, name?: string) => Promise<boolean>;
80
86
  }
package/dist/skills.js CHANGED
@@ -163,8 +163,9 @@ function liveSkills(source, options) {
163
163
  }
164
164
  async function publish(next) {
165
165
  const refused = await refusal(next);
166
+ // The listing in effect stays, so its errors stay too: the refusal is added to them, never replaces them.
166
167
  if (refused !== undefined) {
167
- options.onDiagnostics?.([{ path: '', message: `Listing refused by onPublish: ${refused}` }]);
168
+ options.onDiagnostics?.([...state.errors, { path: '', message: `Listing refused by onPublish: ${refused}` }]);
168
169
  return false;
169
170
  }
170
171
  state = next;
@@ -192,8 +193,9 @@ function liveSkills(source, options) {
192
193
  miss: (context) => missing ??= serial(() => check(context)).finally(() => { missing = undefined; }),
193
194
  refresh: (context, name) => serial(async () => {
194
195
  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;
196
+ // The read is accounted for at this version, whether all of it or one skill was taken (or onPublish refused it): the
197
+ // others kept by a one-skill refresh are offered again only once the source changes, not at the next maxAgeMs check.
198
+ version = next;
197
199
  checkedAt = Date.now();
198
200
  return publish(name === undefined ? listing : only(name, listing, state));
199
201
  }),
@@ -203,6 +205,26 @@ const INTRO_TEXT = 'Before starting a task that matches a skill, call load_skill
203
205
  const COMPANION_LIMIT = 200;
204
206
  /** Each skill's companion files, checked when its listing was read. */
205
207
  const companionLists = new WeakMap();
208
+ const notText = (text) => /[\u0000\uFFFD]/.test(text);
209
+ /**
210
+ * Why a listed companion may not be read now, or `undefined`: the skill's folder and every folder on the way must still be
211
+ * directories and the file a regular file (`fileInfo` does not follow links), so a link swapped in since the listing is refused.
212
+ */
213
+ async function companionChanged(fs, folder, relative, context) {
214
+ const parts = relative.split('/'), base = folder === '.' ? '' : `${folder}/`;
215
+ const steps = [...(folder === '.' ? [] : [{ path: folder, kind: 'directory' }]),
216
+ ...parts.map((_, index) => ({ path: base + parts.slice(0, index + 1).join('/'), kind: index === parts.length - 1 ? 'file' : 'directory' }))];
217
+ for (const step of steps) {
218
+ const info = await fs.fileInfo(step.path, context);
219
+ if (!info.ok)
220
+ return info.error.message;
221
+ if (info.value.kind === 'symlink')
222
+ return `${step.path} is a symbolic link`;
223
+ if (info.value.kind !== step.kind)
224
+ return `${step.path} is not a ${step.kind === 'file' ? 'regular file' : 'directory'}`;
225
+ }
226
+ return undefined;
227
+ }
206
228
  /**
207
229
  * Walk the files beside each skill's `SKILL.md` (relative to its folder, depth first in name order, at most
208
230
  * `COMPANION_LIMIT`) and keep those that read as UTF-8 text. A symbolic link, an unreadable or non-text file, and files past
@@ -246,7 +268,7 @@ async function withCompanions(fs, listing, context) {
246
268
  const text = await fs.readTextFile(at(path), context);
247
269
  if (!text.ok)
248
270
  leftOut(path, `Companion file left out: ${text.error.message}`);
249
- else if (/[\u0000\uFFFD]/.test(text.value))
271
+ else if (notText(text.value))
250
272
  leftOut(path, 'Companion file left out: not UTF-8 text');
251
273
  else
252
274
  files.push(path);
@@ -298,12 +320,19 @@ function skillsExtension(name, live, options, fs) {
298
320
  const folder = skillFolders.get(skill);
299
321
  const companions = fs ? companionLists.get(skill) ?? [] : [];
300
322
  if (fs && args.path !== undefined) {
301
- // Only a listed file is read, so the path cannot leave the folder or follow a symbolic link.
323
+ // Only a listed file is read, so the path cannot leave the folder; it is checked again now, so a link swapped in
324
+ // since the listing (the agent may write its own skill folder) is refused rather than followed.
302
325
  const path = args.path.replace(/^(\.\/)+/, '');
303
- if (!companions.includes(path))
326
+ if (!companions.includes(path) || folder === undefined)
304
327
  return text(`Skill "${skill.name}" has no companion file "${args.path}". Companion files: ${companions.join(', ') || 'none'}`);
328
+ const failed = (why) => text(`Companion file "${path}" of skill "${skill.name}" could not be read: ${why}`);
329
+ const changed = await companionChanged(fs, folder, path, context);
330
+ if (changed !== undefined)
331
+ return failed(changed);
305
332
  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}`);
333
+ if (!read.ok)
334
+ return failed(read.error.message);
335
+ return notText(read.value) ? failed('not UTF-8 text') : text(read.value);
307
336
  }
308
337
  const location = folder === undefined ? '' : `\n\nThis skill's folder is \`${folder}\`; paths in it are relative to that folder.`;
309
338
  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')}` : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.4.24",
3
+ "version": "0.4.25",
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.24",
164
- "@hachej/boring-ui-kit": "^0.4.24",
163
+ "@hachej/boring-files": "^0.4.25",
164
+ "@hachej/boring-ui-kit": "^0.4.25",
165
165
  "xlsx": "^0.20.3"
166
166
  },
167
167
  "peerDependenciesMeta": {