@hachej/boring-agent 0.4.25 → 0.4.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -9
- package/dist/actions.d.ts +38 -0
- package/dist/actions.js +158 -0
- package/dist/agents.d.ts +9 -5
- package/dist/agents.js +11 -5
- package/dist/mentions.d.ts +2 -2
- package/dist/mentions.js +2 -2
- package/dist/run-ledger.d.ts +98 -0
- package/dist/run-ledger.js +194 -0
- package/dist/run-outbox.d.ts +108 -0
- package/dist/run-outbox.js +115 -0
- package/dist/run-result.d.ts +201 -0
- package/dist/run-result.js +195 -0
- package/dist/runs.d.ts +83 -0
- package/dist/runs.js +83 -0
- package/dist/self-evolving.d.ts +0 -2
- package/dist/self-evolving.js +8 -41
- package/dist/skills.d.ts +67 -23
- package/dist/skills.js +174 -36
- package/package.json +24 -3
package/README.md
CHANGED
|
@@ -327,6 +327,17 @@ The resource must be UTF-8 `application/json`, optionally with `charset=utf-8`.
|
|
|
327
327
|
|
|
328
328
|
Run `node fixtures/agent-definitions.mjs` after building for a fictional definition, explicit native configuration and real document publication. `npm run test:definitions-consumer` installs files/agent archives with pinned registry dependencies, checks strict declarations and repeats exact-read, native publication, reopen and registry-replacement tests. Like the question consumer, this strict fixture explicitly installs the pinned optional model SDK declaration dependencies.
|
|
329
329
|
|
|
330
|
+
## One-call runs
|
|
331
|
+
|
|
332
|
+
A run is one agent asked once, in its own native conversation, for a result: the host starts it by a key, the agent answers by calling a result tool (or in text), the host checks the answer, asks for a corrected one while the repair budget lasts, and hands the end to the operation that started the run. Every home needs the same parts, so they are here once. Pi still owns the turns, the transcript and recovery; these read the transcript and submit native follow-up inputs, and the host keeps its data and its budgets.
|
|
333
|
+
|
|
334
|
+
- `@hachej/boring-agent/run-result` (optional peer `typebox`): `resultConfig` (the card's `result: { schema?, validate?, repairs? }`), `resultParameters`, `resultExecutor` (the result tool's `execute`: checks the call against the schema and the card's validator operation, spends the budget), `settleResult` (wait for the answer, read it, repair it in the run's own conversation with `repair:<conversation>:<n>` inputs, check it once more with `phase: "delivery"`), `runMessage`, `runResult`, `resultState`, `evidenceOf`, `withEvidence`. The validator is an operation the host calls through `invoke(operation, input, key)` as the person whose run it is. `ResultBudget` keeps the stored document shape of the homes that ran this first.
|
|
335
|
+
- `@hachej/boring-agent/runs`: the run ends (`completed`, `invalid_output`, `cancelled`, `interrupted`, `revoked`, `error`), `RunView`, `sumUsage`, `OVER_BUDGET` and `isOverBudget`, and `deliverRunEnd`: bounded attempts under one `deliver:<key>` receipt key, a completed end checked again first and delivered as `invalid_output` when it no longer passes. A delivery that fails is reported, not dropped.
|
|
336
|
+
- `@hachej/boring-agent/run-ledger`: `createRunLedger(sql)` keeps `_host_agent_runs` in the person's own SQLite file; an end happens once (`UPDATE ... RETURNING`), so a cancel and the run's own end can race. `createRunner({ ledger, person, context, has, prepare, invoke, admit?, usage? })` gives `start(agent, input, key, deliverTo)` (the same key is the same run), `status`, `cancel` and `recover`: a run a stopped process left ends `interrupted`, and one that ended without being delivered is delivered. `admit` is the host's spend meter: a refused run is never started and ends `error: over_budget`. The cap, the spend ledger and the prices stay the host's.
|
|
337
|
+
- `@hachej/boring-agent/run-outbox`: `stageStarts(sql, ...)` in the app operation's own transaction, `drainStarts` after the commit. A row is removed only when the run is acknowledged, retried with backoff when it may pass, dropped when it never will.
|
|
338
|
+
|
|
339
|
+
Stores are a synchronous `SyncSql` function (`node:sqlite` `prepare(sql).all(...params)`, a Durable Object's `sql.exec`); nothing here opens a connection or a transaction. Not here, by design: the card parser and its front matter, the app operations a run calls, the schedule and alarm of a Durable Object home, and anything clinical or app specific.
|
|
340
|
+
|
|
330
341
|
## Structured output and native tool evidence
|
|
331
342
|
|
|
332
343
|
`@hachej/boring-agent/validation` exports `createOutputValidation`. Install its native extension explicitly. A producer returns `{ text, evidence }`, where `evidence` contains retained native tool-result entry IDs. Create the returned validation task with `{ producer }` through ordinary `tx.createTask`; it waits for the producer and returns `valid`, `invalid` or `producer-failed`. Only a `valid` result may feed application delivery. The host supplies a stable task name and definition version; incompatible stored versions require native migration or refusal.
|
|
@@ -349,33 +360,34 @@ Each evidence entry must link to a completed native ToolTask, its original assis
|
|
|
349
360
|
|
|
350
361
|
`?op=configure` (POST `{ model?: { provider, modelId }, thinkingLevel? }`) changes the conversation's native agent for a model or effort picker. It is gated by `allow('configure')` and delegated to `ChatTransportAccess.configure(change)`; without that function the op answers `not-supported`. The host function applies `conversation.configure(...)` and owns the allow-list: it validates the change against its own models and levels and returns a refusal such as `{ kind: 'refused', reason }` instead of applying it (the handler checks only the request shape). `createRemoteChat().configure(change)` returns whatever the host answered. A change applies from the next model request.
|
|
351
362
|
|
|
352
|
-
`
|
|
363
|
+
The skills section (`staticSkills` and `liveSkills`) tells the model that a user message starting with `/<skill-name>` is an explicit request to load that skill first, which is what the chat's `/` menu inserts. `defineAgent(...).skills` lists each skill's name and description for such a menu when the skills are an array.
|
|
353
364
|
|
|
354
365
|
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
366
|
|
|
356
367
|
## Live skills
|
|
357
368
|
|
|
358
|
-
`
|
|
369
|
+
`staticSkills(skills, { name, intro?, visible? })` takes an array and returns `{ extension }`. `liveSkills(source, { name, maxAgeMs?, onDiagnostics?, companionFiles?, onPublish?, intro?, visible? })` takes a `SkillSource` instead, so skills added, edited or removed after start reach the agent without rebuilding the extension; `defineAgent({ skills })` takes either. 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
370
|
|
|
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
|
|
371
|
+
- `directorySkillSource(fs, 'skills')`: every `skills/<name>/SKILL.md`, its folder passed to `load_skill` as for self-evolution; `{ flat: true }` adds every `skills/<name>.md` file (self-evolution's `.agent/skills` layout). A missing directory holds no skills; folders without `SKILL.md` are skipped, and companion files are read only with `companionFiles` (below). Its version is each skill file's path with its `mtimeMs` and size (one `listDir` and one `fileInfo` per skill file).
|
|
361
372
|
- `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
373
|
|
|
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 `
|
|
374
|
+
The source is read on first use. After that the listing section and `load_skill` use the cached copy until it is older than `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: `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
375
|
|
|
365
376
|
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
377
|
|
|
367
378
|
**Control (all optional; with none set, the extension behaves as above).**
|
|
368
379
|
|
|
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
|
|
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.
|
|
380
|
+
- `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 `staticSkills` too.
|
|
381
|
+
- `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. A source without `fs` throws.
|
|
371
382
|
- `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
383
|
- `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
|
-
- `
|
|
384
|
+
- `liveSkills(source, options)` returns `{ extension, refresh }`; `staticSkills` returns `{ extension }` only. `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
385
|
|
|
375
386
|
**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
387
|
|
|
377
388
|
```ts
|
|
378
|
-
const skills =
|
|
389
|
+
const skills = liveSkills(directorySkillSource(env, 'skills'), {
|
|
390
|
+
name: 'workspace.skills',
|
|
379
391
|
companionFiles: true,
|
|
380
392
|
visible: (conversationId, skills) => billingConversations.has(conversationId) ? skills : skills.filter(skill => !skill.name.startsWith('billing-')),
|
|
381
393
|
onDiagnostics: errors => errors.forEach(error => log.warn(`skill ${error.path}: ${error.message}`)),
|
|
@@ -386,6 +398,27 @@ const agent = defineAgent({ id: 'clerk', model, extensions: [skills.extension] }
|
|
|
386
398
|
await skills.refresh(context, 'letters'); // after the host saved skills/letters/SKILL.md
|
|
387
399
|
```
|
|
388
400
|
|
|
401
|
+
### Versioned skill folders
|
|
402
|
+
|
|
403
|
+
`directorySkillSource(fs, root, { versioned: true })` reads skills published as whole versions, for example from a bucket mounted read-only into the agent runtime (the library only reads a folder through the workspace `FileSystem`; it does not know where the bytes live):
|
|
404
|
+
|
|
405
|
+
```
|
|
406
|
+
<root>/v12/<skill>/SKILL.md (+ companion files)
|
|
407
|
+
<root>/v13/<skill>/SKILL.md
|
|
408
|
+
<root>/current one line: v13
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
- **Publish**: write the new version folder completely, then overwrite `current` with its name. A version folder is never changed once written (the library reads each version once and keeps it).
|
|
412
|
+
- **Rollback**: write the old version's name back into `current`.
|
|
413
|
+
- **Which version a conversation sees**: a new conversation starts on the version `current` names (read again once older than `maxAgeMs`). A running conversation keeps the version it started on until the person sends `/reload-skill`, which moves that conversation only to the version `current` names now (reading that version's folder again). `{ flat: true }` applies inside each version folder.
|
|
414
|
+
- **A bad pointer**: `current` missing or unreadable, a value that is not one folder name (`../v1`), or a version with no folder is reported to `onDiagnostics` as `{ path: '<root>/current', message }`; new conversations stay on the last good version, and `/reload-skill` answers `Skills stay on version <v>: <message>`. Before any good version, the agent has no skills.
|
|
415
|
+
|
|
416
|
+
`/reload-skill` is the skills extension's native tool `reload_skill` (Pi extension point: a tool and the listing section of the extension, over the `SkillSource` on the workspace's `ExecutionEnv` `FileSystem`; no second loader). The listing tells the model that a message `/reload-skill` means calling it, as `/<skill-name>` means `load_skill`; the tool answers `Skills reloaded: version <v> (<names>).` A host command can do the same without the model: `refresh(context, undefined, conversationId)` moves one conversation, `refresh(context)` every conversation, and resolves `false` on a bad pointer. `refresh(context, name)` is refused (versions move whole), and so is `onPublish`. Limit: which version each conversation is on is kept in the process, so after a restart every conversation starts on the version `current` names then.
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
const agent = defineAgent({ id: 'clerk', model, skills: directorySkillSource(env, '/mnt/skills', { versioned: true }) });
|
|
420
|
+
```
|
|
421
|
+
|
|
389
422
|
## Sub-agents and schedules
|
|
390
423
|
|
|
391
424
|
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.
|
|
@@ -439,7 +472,7 @@ const delegates = [
|
|
|
439
472
|
|
|
440
473
|
`defineAgent({ …, selfEvolving: true, workspace })` lets the agent keep its own standing instructions, skills and tools in the `.agent/` folder of its workspace and apply them with a `reload` tool; the plan and its reasons are in [SELF-EVOLUTION.md](../../docs/architecture/SELF-EVOLUTION.md). Off by default.
|
|
441
474
|
|
|
442
|
-
- One native extension per workspace, named `self-evolving:<workspace>`, so two workspaces in one host never replace each other's tools. Without a fixed `workspace` the agent works in a workspace per conversation: `inWorkspace(name)` returns the agent of workspace `name` (made once, then the same), whose configuration selects only `self-evolving:<name>`; the host creates each conversation with the agent of its owner's workspace and installs and reloads that workspace's extension when the workspace opens, so one harness and one registry never offer one workspace's agent-written tools to another's. It carries the skills (the host's, then `.agent/skills/*.md` and `.agent/skills/<name>/SKILL.md
|
|
475
|
+
- One native extension per workspace, named `self-evolving:<workspace>`, so two workspaces in one host never replace each other's tools. Without a fixed `workspace` the agent works in a workspace per conversation: `inWorkspace(name)` returns the agent of workspace `name` (made once, then the same), whose configuration selects only `self-evolving:<name>`; the host creates each conversation with the agent of its owner's workspace and installs and reloads that workspace's extension when the workspace opens, so one harness and one registry never offer one workspace's agent-written tools to another's. It carries the skills (the host's, then `.agent/skills/*.md` and `.agent/skills/<name>/SKILL.md`, read with `directorySkillSource(env, '.agent/skills', { flat: true })`, behind the usual `load_skill`), `reload`, the agent-written tools and the `agent-written` prompt section. It is selected last; the host's `instructions` become the first section (`host-instructions`) of `agent.<id>`, because Pi renders native `instructions` after every extension section.
|
|
443
476
|
- `.agent/AGENTS.md` is rendered by that section on every request through the request's environment, labelled as agent-written and capped at `MAX_AGENT_INSTRUCTIONS` (16,000) characters.
|
|
444
477
|
- `.agent/tools/<name>.json` is `{ name, description, parameters, run }`: `name` snake_case and not one of the agent's other tools, `parameters` a JSON Schema with `"type": "object"`, `run` one command line; a path under `.agent/` that `run` names must exist. Each becomes a native `ToolRegistration` (through `jsonSchemaTool`, so Pi refuses arguments that do not match `parameters` before `run` starts) whose `execute` runs `run` through the call's own `ExecutionEnv.exec` (the same provider instance as the files it was read from), with the arguments as one line of JSON on standard input (a quoted here-document); the output is the result and a non-zero exit is an error.
|
|
445
478
|
- `reload` (and `DefinedAgent.reload(env, context)`, which a host calls on open and for the person's `/reload`) reads `.agent/` through the given environment's `FileSystem`, rebuilds the extension and calls the native `registry.install()` in every registry `install` was given (same name: replaced in place). Reloads are serialised. The report (`SelfEvolutionReport`, its `text` is the tool result) names tools added, changed and removed, the agent's skills, the instructions and every load error (invalid JSON, schema, missing script, name conflict, unreadable file). A reload never imports or evaluates anything. Without `exec` the tools are reported unavailable and skipped; instructions and skills still load.
|
|
@@ -488,3 +521,33 @@ const navigate = createPresentationTool({
|
|
|
488
521
|
Here `Type` is Pi AI's native schema builder, and `mounted`, `capturedTarget` and `canNavigate` belong to the host. The adapter passes native cancellation to the command and fixes replay to `unsafe`: an interrupted presentation call cannot safely be redirected or repeated. It acquires no controller, provider or Harness and owns none of their cleanup.
|
|
489
522
|
|
|
490
523
|
This is direct in-process binding. A server cannot manipulate a remote browser through this adapter alone. The optional [canvas host recipe](../../docs/architecture/CANVAS.md#authenticated-remote-presentation-recipe) supplies authenticated selection/proposal delivery to a mounted canvas. Other viewer transports and deployment identity remain separate work. Presentation/proposal results are not publication receipts or human approval.
|
|
524
|
+
|
|
525
|
+
## Structured actions
|
|
526
|
+
|
|
527
|
+
`@hachej/boring-agent/actions` provides `withActionModels(models, { resolve })`, an optional adapter at the public `HarnessOptions.models` seam. A host decision admits one structured extraction; its single validated tool call is executed by Pi. The next native generation renders a template from the actual tool result, without contacting an LLM. Ambiguous requests return to the full model. No extra harness, tool dispatcher or durable journal is created.
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
import { ACTION_PROVIDER, withActionModels } from '@hachej/boring-agent/actions';
|
|
531
|
+
|
|
532
|
+
const actionModels = withActionModels(admittedModels, {
|
|
533
|
+
resolve: id => id === 'notes' ? {
|
|
534
|
+
model: fullModel,
|
|
535
|
+
extraction: { model: extractionModel, instructions: 'Extract one new note.', tool: noteTool },
|
|
536
|
+
decide: async ({ message, history, signal }) => decideNote({ message, history, signal }),
|
|
537
|
+
reply: 'Saved {note.title}.',
|
|
538
|
+
uncertainReply: 'The outcome is uncertain. Check before retrying.',
|
|
539
|
+
} : undefined,
|
|
540
|
+
});
|
|
541
|
+
// Pass actionModels as the models option when the host opens its native Harness.
|
|
542
|
+
await conversation.configure({ model: { provider: ACTION_PROVIDER, modelId: 'notes' } }, context);
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
`admittedModels` is the host's existing native Models, including its provider authentication and budget wrappers. `noteTool` is a native tool declaration, already installed and allowed in the conversation. The extraction schema may be stricter than that tool's schema; both are checked without coercion before returning a call. No commercial provider, API key, subscription or Jev client is embedded in the package. The host's `decide` adapts its chosen decision service; only literal `true` admits extraction. The callback gets native prior messages so the host can choose and bound the authorized decision context. Extraction receives only the current text input.
|
|
546
|
+
|
|
547
|
+
The default decision deadline is 400 ms and the input cap is 1,500 characters; `decisionTimeoutMs` and `maxInputCharacters` override them. Decision failure/timeout, non-text input, missing current tool, malformed/multiple extraction calls or extraction refusal return to the full model. The decision receives an abort signal; an uncooperative service may finish later, but its result cannot start extraction. Native compaction uses `completeSimple` with a tool-free summarization context, which delegates to the full model. Explicit deferred requests also use the full model; polling and cancellation retain the underlying provider. Missing action definitions or full models return the native unavailable-model result. Unselected models and native registry capabilities remain available.
|
|
548
|
+
|
|
549
|
+
Reply placeholders such as `{note.title}` accept actual strings, finite numbers and string arrays (joined by newline). Missing output, a failed tool or unreadable result produces `uncertainReply`, never a blind replay of a possible mutation. `rejectedWithoutEffect(result)` may opt into full-model handling only when the host can prove an atomic rejection; a throwing predicate leaves the outcome uncertain. Tools still own permission checks and durable idempotent receipts; the adapter cannot make external writes transactional. Install the adapter outside the metered Models so real extraction and fallback requests retain admission. Synthetic replies have zero usage; failed extraction usage needs host request metering because only the final fallback message enters Pi's transcript.
|
|
550
|
+
|
|
551
|
+
Hosts must explicitly configure existing conversations when adopting this virtual model; deployment alone does not change their persisted selection. Keep the action's tool and result contract compatible while native work is pending. Changing definitions during an outstanding action is not a version-pinning guarantee.
|
|
552
|
+
|
|
553
|
+
Evidence: `node --test test/packages/agent-actions.test.mjs` runs the public package through a native Harness, SQLite reopen, native tool gates and scripted models. `npm run test:definitions-consumer -- --actions` repeats these tests and checks strict installed declarations. Real-model speed and decision accuracy remain consumer evals, not claims of these scripted tests.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Message, Models, Tool, ToolResultMessage } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ModelRef } from '@earendil-works/pi-durable';
|
|
3
|
+
/** Virtual model identity persisted by Pi; real requests use the host's Models. */
|
|
4
|
+
export declare const ACTION_PROVIDER = "boring-action";
|
|
5
|
+
export interface StructuredAction {
|
|
6
|
+
readonly model: ModelRef;
|
|
7
|
+
readonly extraction: {
|
|
8
|
+
readonly model: ModelRef;
|
|
9
|
+
readonly instructions: string;
|
|
10
|
+
readonly tool: Tool;
|
|
11
|
+
};
|
|
12
|
+
/** Host decision policy (Jev, another classifier, or deterministic rules). Literal true admits extraction. */
|
|
13
|
+
readonly decide: (input: {
|
|
14
|
+
readonly message: string;
|
|
15
|
+
readonly history: readonly Message[];
|
|
16
|
+
readonly signal: AbortSignal;
|
|
17
|
+
}) => Promise<boolean>;
|
|
18
|
+
readonly reply: string;
|
|
19
|
+
/** Host-localized text for an outcome that must not be retried automatically. */
|
|
20
|
+
readonly uncertainReply: string;
|
|
21
|
+
/** Only return true for a known atomic rejection with no effect. */
|
|
22
|
+
readonly rejectedWithoutEffect?: (result: ToolResultMessage) => boolean;
|
|
23
|
+
readonly decisionTimeoutMs?: number;
|
|
24
|
+
readonly maxInputCharacters?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface ActionModelsOptions {
|
|
27
|
+
/** Resolve trusted configuration; this never registers tools or grants permissions. */
|
|
28
|
+
readonly resolve: (id: string) => StructuredAction | undefined;
|
|
29
|
+
readonly now?: () => number;
|
|
30
|
+
}
|
|
31
|
+
/** Substitute actual scalar results only. Missing values cannot become invented confirmations. */
|
|
32
|
+
export declare function actionReply(template: string, result: unknown): string;
|
|
33
|
+
/** Compose at HarnessOptions.models, outside the host's admitted/metered real Models.
|
|
34
|
+
* Pi still owns generation, tool execution, hooks, history and recovery. Tool-free requests
|
|
35
|
+
* (including native compaction) delegate to the full model. No tool is executed here.
|
|
36
|
+
*/
|
|
37
|
+
export declare function withActionModels<T extends Models>(models: T, options: ActionModelsOptions): T;
|
|
38
|
+
//# sourceMappingURL=actions.d.ts.map
|
package/dist/actions.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createAssistantMessageEventStream, getCurrentTools, normalizeContext } from '@earendil-works/pi-ai';
|
|
2
|
+
import { Check } from 'typebox/schema';
|
|
3
|
+
/** Virtual model identity persisted by Pi; real requests use the host's Models. */
|
|
4
|
+
export const ACTION_PROVIDER = 'boring-action';
|
|
5
|
+
const PREFIX = 'boring_action_';
|
|
6
|
+
const zeroUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
|
|
7
|
+
const inputText = (content) => typeof content === 'string' ? content : content.filter(p => p.type === 'text').map(p => p.text).join('');
|
|
8
|
+
/** Substitute actual scalar results only. Missing values cannot become invented confirmations. */
|
|
9
|
+
export function actionReply(template, result) {
|
|
10
|
+
return template.replace(/\{([\w.]+)\}/g, (_match, path) => {
|
|
11
|
+
let value = result;
|
|
12
|
+
for (const key of path.split('.')) {
|
|
13
|
+
value = value !== null && typeof value === 'object' && Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined;
|
|
14
|
+
}
|
|
15
|
+
if (Array.isArray(value) && value.every(v => typeof v === 'string'))
|
|
16
|
+
value = value.join('\n');
|
|
17
|
+
if (typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value)))
|
|
18
|
+
throw new Error(`Missing action result: ${path}`);
|
|
19
|
+
return String(value);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async function admitted(action, message, history, parent) {
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const signal = parent ? AbortSignal.any([parent, controller.signal]) : controller.signal;
|
|
25
|
+
if (signal.aborted)
|
|
26
|
+
return false;
|
|
27
|
+
let timer;
|
|
28
|
+
let onAbort = () => { };
|
|
29
|
+
const cancelled = new Promise(resolve => {
|
|
30
|
+
onAbort = () => resolve(false);
|
|
31
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
32
|
+
timer = setTimeout(() => controller.abort(), action.decisionTimeoutMs ?? 400);
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
return await Promise.race([Promise.resolve().then(() => action.decide({ message, history, signal })).then(value => value === true, () => false), cancelled]);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
signal.removeEventListener('abort', onAbort);
|
|
40
|
+
controller.abort();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Compose at HarnessOptions.models, outside the host's admitted/metered real Models.
|
|
44
|
+
* Pi still owns generation, tool execution, hooks, history and recovery. Tool-free requests
|
|
45
|
+
* (including native compaction) delegate to the full model. No tool is executed here.
|
|
46
|
+
*/
|
|
47
|
+
export function withActionModels(models, options) {
|
|
48
|
+
const now = options.now ?? Date.now;
|
|
49
|
+
const required = (ref) => {
|
|
50
|
+
const model = models.getModel(ref.provider, ref.modelId);
|
|
51
|
+
if (!model || model.provider === ACTION_PROVIDER)
|
|
52
|
+
throw new Error(`Unavailable action model: ${ref.provider}/${ref.modelId}`);
|
|
53
|
+
return model;
|
|
54
|
+
};
|
|
55
|
+
const resolve = (id) => {
|
|
56
|
+
const action = options.resolve(id);
|
|
57
|
+
if (!action)
|
|
58
|
+
throw new Error(`No action agent ${id}`);
|
|
59
|
+
return action;
|
|
60
|
+
};
|
|
61
|
+
const wrapped = {
|
|
62
|
+
getModel(provider, id) {
|
|
63
|
+
if (provider !== ACTION_PROVIDER)
|
|
64
|
+
return models.getModel(provider, id);
|
|
65
|
+
const action = options.resolve(id);
|
|
66
|
+
const model = action && models.getModel(action.model.provider, action.model.modelId);
|
|
67
|
+
return model && model.provider !== ACTION_PROVIDER ? { ...model, provider, id } : undefined;
|
|
68
|
+
},
|
|
69
|
+
streamSimple(model, context, requestOptions) {
|
|
70
|
+
if (model.provider !== ACTION_PROVIDER)
|
|
71
|
+
return models.streamSimple(model, context, requestOptions);
|
|
72
|
+
const action = resolve(model.id);
|
|
73
|
+
const original = required(action.model);
|
|
74
|
+
const out = createAssistantMessageEventStream();
|
|
75
|
+
const full = async () => { for await (const event of models.streamSimple(original, context, requestOptions))
|
|
76
|
+
out.push(event); };
|
|
77
|
+
const emit = (message) => {
|
|
78
|
+
if (message.stopReason === 'error' || message.stopReason === 'aborted')
|
|
79
|
+
out.push({ type: 'error', reason: message.stopReason, error: message });
|
|
80
|
+
else if (message.stopReason !== 'pending')
|
|
81
|
+
out.push({ type: 'done', reason: message.stopReason, message });
|
|
82
|
+
else
|
|
83
|
+
throw new Error('An action cannot emit a pending completion');
|
|
84
|
+
};
|
|
85
|
+
void (async () => {
|
|
86
|
+
const messages = context.messages.filter(message => message.role !== 'system');
|
|
87
|
+
const last = messages.at(-1), previous = messages.at(-2);
|
|
88
|
+
const calls = previous?.role === 'assistant' ? previous.content.filter(p => p.type === 'toolCall') : [];
|
|
89
|
+
const call = calls.length === 1 ? calls[0] : undefined;
|
|
90
|
+
if (last?.role === 'toolResult' && call?.id === last.toolCallId && call.name === action.extraction.tool.name && call.id.startsWith(PREFIX)) {
|
|
91
|
+
if (last.isError) {
|
|
92
|
+
let rejected = false;
|
|
93
|
+
try {
|
|
94
|
+
rejected = action.rejectedWithoutEffect?.(last) === true;
|
|
95
|
+
}
|
|
96
|
+
catch { /* Unknown outcomes remain uncertain. */ }
|
|
97
|
+
if (rejected)
|
|
98
|
+
return full();
|
|
99
|
+
}
|
|
100
|
+
let text = action.uncertainReply;
|
|
101
|
+
if (!last.isError) {
|
|
102
|
+
try {
|
|
103
|
+
text = actionReply(action.reply, JSON.parse(inputText(last.content)));
|
|
104
|
+
}
|
|
105
|
+
catch { /* A possible effect must not be repeated. */ }
|
|
106
|
+
}
|
|
107
|
+
emit({ role: 'assistant', content: [{ type: 'text', text }], api: original.api, provider: ACTION_PROVIDER, model: model.id, usage: zeroUsage(), stopReason: 'stop', timestamp: now() });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const tool = getCurrentTools(normalizeContext(context).messages).find(tool => tool.name === action.extraction.tool.name);
|
|
111
|
+
if (!tool || last?.role !== 'user' || requestOptions?.signal?.aborted || requestOptions?.deferred)
|
|
112
|
+
return full();
|
|
113
|
+
const raw = inputText(last.content);
|
|
114
|
+
if (!raw.trim() || raw.length > (action.maxInputCharacters ?? 1500) || (Array.isArray(last.content) && last.content.some(p => p.type !== 'text')))
|
|
115
|
+
return full();
|
|
116
|
+
if (!await admitted(action, raw, messages.slice(0, -1), requestOptions?.signal) || requestOptions?.signal?.aborted)
|
|
117
|
+
return full();
|
|
118
|
+
let extracted;
|
|
119
|
+
try {
|
|
120
|
+
extracted = await models.streamSimple(required(action.extraction.model), {
|
|
121
|
+
systemPrompt: action.extraction.instructions, messages: [last], tools: [action.extraction.tool],
|
|
122
|
+
}, requestOptions).result();
|
|
123
|
+
const extractedCalls = extracted.content.filter(p => p.type === 'toolCall');
|
|
124
|
+
const extractedCall = extractedCalls.length === 1 ? extractedCalls[0] : undefined;
|
|
125
|
+
if (extracted.stopReason !== 'toolUse' || !extractedCall || extractedCall.name !== tool.name ||
|
|
126
|
+
!Check(action.extraction.tool.parameters, extractedCall.arguments) || !Check(tool.parameters, extractedCall.arguments))
|
|
127
|
+
return full();
|
|
128
|
+
// Native ToolTask owns the write, permission checks and host receipt; keep this ID in its transcript.
|
|
129
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
130
|
+
const id = PREFIX + Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
131
|
+
emit({ ...extracted, content: [{ ...extractedCall, id }] });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return full();
|
|
135
|
+
}
|
|
136
|
+
})().catch(error => emit({ role: 'assistant', content: [], api: original.api, provider: original.provider, model: original.id, usage: zeroUsage(), stopReason: 'error', errorMessage: String(error instanceof Error ? error.message : error), timestamp: now() })).finally(() => out.end());
|
|
137
|
+
return out;
|
|
138
|
+
},
|
|
139
|
+
stream(model, context, requestOptions) {
|
|
140
|
+
return models.stream(model.provider === ACTION_PROVIDER ? required(resolve(model.id).model) : model, context, requestOptions);
|
|
141
|
+
},
|
|
142
|
+
completeSimple: (model, context, requestOptions) => wrapped.streamSimple(model, context, requestOptions).result(),
|
|
143
|
+
complete: (model, context, requestOptions) => wrapped.stream(model, context, requestOptions).result(),
|
|
144
|
+
streamDeferred(model, handle, requestOptions) {
|
|
145
|
+
return models.streamDeferred(model.provider === ACTION_PROVIDER ? required(resolve(model.id).model) : model, handle, requestOptions);
|
|
146
|
+
},
|
|
147
|
+
fetchDeferred: (model, handle, requestOptions) => wrapped.streamDeferred(model, handle, requestOptions).result(),
|
|
148
|
+
cancelDeferred(model, handle, requestOptions) {
|
|
149
|
+
return models.cancelDeferred(model.provider === ACTION_PROVIDER ? required(resolve(model.id).model) : model, handle, requestOptions);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
return new Proxy(models, { get(target, key) {
|
|
153
|
+
if (Object.hasOwn(wrapped, key))
|
|
154
|
+
return Reflect.get(wrapped, key);
|
|
155
|
+
const value = Reflect.get(target, key);
|
|
156
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
157
|
+
} });
|
|
158
|
+
}
|
package/dist/agents.d.ts
CHANGED
|
@@ -3,10 +3,10 @@ import type { Context } from '@earendil-works/chord';
|
|
|
3
3
|
import type { ExecutionEnv } from '@earendil-works/pi-durable/env';
|
|
4
4
|
import type { SelfEvolutionReport } from './self-evolving.js';
|
|
5
5
|
import type { DelegateOptions } from './delegate.js';
|
|
6
|
-
import type { Skill } from './skills.js';
|
|
6
|
+
import type { Skill, SkillSource } from './skills.js';
|
|
7
7
|
export type { SelfEvolutionError, SelfEvolutionReport } from './self-evolving.js';
|
|
8
|
-
export {
|
|
9
|
-
export type { Skill, SkillError, SkillFileSystem, SkillListing, SkillSource,
|
|
8
|
+
export { directorySkillSource, fileSkillSource, liveSkills, parseSkill, staticSkills } from './skills.js';
|
|
9
|
+
export type { LiveSkills, LiveSkillsOptions, Skill, SkillError, SkillFileSystem, SkillListing, SkillSource, SkillsOptions, SkillVersions } 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';
|
|
@@ -22,7 +22,11 @@ export interface AgentSpec {
|
|
|
22
22
|
readonly tools?: readonly ToolRegistration[];
|
|
23
23
|
/** Additional native extensions this agent selects, for example Pi's `CodingTools`. */
|
|
24
24
|
readonly extensions?: readonly Extension[];
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The agent's skills: an array, or a `SkillSource` (`directorySkillSource`, `fileSkillSource` or a host's own) read on use with
|
|
27
|
+
* `liveSkills`. A source cannot be combined with `selfEvolving`.
|
|
28
|
+
*/
|
|
29
|
+
readonly skills?: readonly Skill[] | SkillSource;
|
|
26
30
|
/**
|
|
27
31
|
* Sub-agents this agent may hand a task to: each becomes a `delegate` tool (`createDelegateTool`) among this agent's tools, and
|
|
28
32
|
* `install` installs the child agent's extensions too. The child runs in a conversation owned by the tool call, with only its own tools.
|
|
@@ -47,7 +51,7 @@ export interface DefinedAgent {
|
|
|
47
51
|
readonly id: string;
|
|
48
52
|
/** Every native extension this agent selects, own extension first; with `selfEvolving` the per-workspace extension (as first defined) last. */
|
|
49
53
|
readonly extensions: readonly Extension[];
|
|
50
|
-
/** Name and description of this agent's skills, for hosts that offer them in a `/` menu. */
|
|
54
|
+
/** Name and description of this agent's skills (an array given to `skills`), for hosts that offer them in a `/` menu. A source's skills are read at run time and not listed. */
|
|
51
55
|
readonly skills: readonly Pick<Skill, 'name' | 'description'>[];
|
|
52
56
|
/** Native per-conversation agent configuration. */
|
|
53
57
|
readonly agent: AgentChange;
|
package/dist/agents.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { AgentDoc, defineExtension } from '@earendil-works/pi-durable';
|
|
3
3
|
import { createSelfEvolution } from './self-evolving.js';
|
|
4
|
-
import { NAME,
|
|
4
|
+
import { NAME, isSkillArray, liveSkills, staticSkills, validSkill } from './skills.js';
|
|
5
5
|
import { createDelegateTool } from './delegate.js';
|
|
6
|
-
export {
|
|
6
|
+
export { directorySkillSource, fileSkillSource, liveSkills, parseSkill, staticSkills } 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. */
|
|
@@ -23,7 +23,13 @@ export function defineAgent(spec) {
|
|
|
23
23
|
// The skills of a self-evolving agent live in its per-workspace extension, so agent-written skills join them on reload. Without a
|
|
24
24
|
// workspace (a self-evolving agent whose workspace is chosen per conversation, before `inWorkspace`) they stay the host's alone.
|
|
25
25
|
const evolving = spec.selfEvolving === true && spec.workspace !== undefined;
|
|
26
|
-
|
|
26
|
+
if (spec.selfEvolving && spec.skills !== undefined && !isSkillArray(spec.skills))
|
|
27
|
+
throw new TypeError(`Agent ${spec.id}: selfEvolving takes an array of skills, not a SkillSource`);
|
|
28
|
+
const fixed = spec.skills === undefined ? [] : isSkillArray(spec.skills) ? spec.skills : [];
|
|
29
|
+
const skillsName = `agent.${spec.id}.skills`;
|
|
30
|
+
const hostSkills = spec.skills === undefined || evolving ? [] : isSkillArray(spec.skills)
|
|
31
|
+
? (spec.skills.length ? [staticSkills(spec.skills, { name: skillsName }).extension] : [])
|
|
32
|
+
: [liveSkills(spec.skills, { name: skillsName }).extension];
|
|
27
33
|
const listed = (skills) => Object.freeze(skills.map(skill => Object.freeze({ name: skill.name, description: skill.description.trim() })));
|
|
28
34
|
const reserved = [...tools, ...(spec.extensions ?? []).flatMap(extension => extension.tools ?? [])].map(tool => tool.name);
|
|
29
35
|
/** The agent as one native configuration: its own extensions, the host's, and with `evolution` the per-workspace one last. */
|
|
@@ -38,7 +44,7 @@ export function defineAgent(spec) {
|
|
|
38
44
|
...(spec.thinkingLevel === undefined ? {} : { thinkingLevel: spec.thinkingLevel }),
|
|
39
45
|
...(spec.cwd === undefined ? {} : { cwd: spec.cwd }),
|
|
40
46
|
});
|
|
41
|
-
const fixedSkills = listed(
|
|
47
|
+
const fixedSkills = listed(fixed);
|
|
42
48
|
return Object.freeze({
|
|
43
49
|
id: spec.id, extensions, agent,
|
|
44
50
|
get skills() { return evolution ? listed(evolution.skills()) : fixedSkills; },
|
|
@@ -58,7 +64,7 @@ export function defineAgent(spec) {
|
|
|
58
64
|
});
|
|
59
65
|
}
|
|
60
66
|
const evolutionOf = (workspace) => createSelfEvolution({
|
|
61
|
-
name: `self-evolving:${workspace}`, skills:
|
|
67
|
+
name: `self-evolving:${workspace}`, skills: fixed.map(validSkill), reserved,
|
|
62
68
|
});
|
|
63
69
|
if (!spec.selfEvolving)
|
|
64
70
|
return assemble([...own, ...hostSkills], undefined);
|
package/dist/mentions.d.ts
CHANGED
|
@@ -33,8 +33,8 @@ export declare const MENTION_LIMITS: Readonly<{
|
|
|
33
33
|
}>;
|
|
34
34
|
/** Every part this module adds starts with this prefix, so a viewer can tell it from what the person typed. */
|
|
35
35
|
export declare const MENTION_FILE_PREFIX = "<file path=\"";
|
|
36
|
-
/** The `@path` tokens of a message: at the start or after whitespace; a bare path drops trailing punctuation, a quoted `@"a b.md"` (escapes `\"`, `\\`) is taken as written. The syntax
|
|
37
|
-
* parser (`MENTION_TOKEN` and `pieces`
|
|
36
|
+
/** The `@path` tokens of a message: at the start or after whitespace; a bare path drops trailing punctuation, a quoted `@"a b.md"` (escapes `\"`, `\\`) is taken as written. The syntax the chat writes. The browser cannot import this, so the kit's `chat-text` holds the
|
|
37
|
+
* same parser (`mentionedPaths`, `MENTION_TOKEN` and `pieces` of `@hachej/boring-ui-kit/chat-text`); `test/packages/ui-chat-text.test.mjs` keeps the two equal. */
|
|
38
38
|
export declare function mentionedPaths(text: string): string[];
|
|
39
39
|
/** A workspace-relative path with no absolute prefix, no `..`, no backslash and no control characters. */
|
|
40
40
|
export declare function safeMentionPath(path: string): boolean;
|
package/dist/mentions.js
CHANGED
|
@@ -3,8 +3,8 @@ import { SNIFF_BYTES, fileKind, nativeImageType } from './file-types.js';
|
|
|
3
3
|
export const MENTION_LIMITS = Object.freeze({ fileBytes: 100_000, totalBytes: 300_000, files: 20 });
|
|
4
4
|
/** Every part this module adds starts with this prefix, so a viewer can tell it from what the person typed. */
|
|
5
5
|
export const MENTION_FILE_PREFIX = '<file path="';
|
|
6
|
-
/** The `@path` tokens of a message: at the start or after whitespace; a bare path drops trailing punctuation, a quoted `@"a b.md"` (escapes `\"`, `\\`) is taken as written. The syntax
|
|
7
|
-
* parser (`MENTION_TOKEN` and `pieces`
|
|
6
|
+
/** The `@path` tokens of a message: at the start or after whitespace; a bare path drops trailing punctuation, a quoted `@"a b.md"` (escapes `\"`, `\\`) is taken as written. The syntax the chat writes. The browser cannot import this, so the kit's `chat-text` holds the
|
|
7
|
+
* same parser (`mentionedPaths`, `MENTION_TOKEN` and `pieces` of `@hachej/boring-ui-kit/chat-text`); `test/packages/ui-chat-text.test.mjs` keeps the two equal. */
|
|
8
8
|
export function mentionedPaths(text) {
|
|
9
9
|
const found = new Set();
|
|
10
10
|
for (const match of text.matchAll(/(^|\s)@(?:"((?:[^"\\]|\\.)*)"|([^\s]+))/g)) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Context } from '@earendil-works/chord';
|
|
2
|
+
import type { SyncSql } from './run-outbox.js';
|
|
3
|
+
import type { ResultCard, ResultConversation, ResultInvoke, ResultSubmission, ResultValidationRecord } from './run-result.js';
|
|
4
|
+
import type { RunEnd, RunUsage, RunView } from './runs.js';
|
|
5
|
+
/**
|
|
6
|
+
* The runs a home keeps in a person's own store, and the runner over them: start a run by its key (the same key is the same run),
|
|
7
|
+
* end it exactly once, deliver its end to the operation that started it, cancel it, and find again what a stopped process left.
|
|
8
|
+
* The store is the host's data (a table in its SQLite file); the model turns stay Pi's (the run is a native conversation).
|
|
9
|
+
*/
|
|
10
|
+
export declare const RUNS_TABLE = "CREATE TABLE IF NOT EXISTS _host_agent_runs (key TEXT PRIMARY KEY, agent TEXT NOT NULL, status TEXT NOT NULL, output TEXT, error TEXT, deliver TEXT, validation TEXT, delivered INTEGER NOT NULL DEFAULT 0, person TEXT NOT NULL, usage TEXT, started_at TEXT)";
|
|
11
|
+
export interface RunRow {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly agent: string;
|
|
14
|
+
readonly status: string;
|
|
15
|
+
readonly output: string | null;
|
|
16
|
+
readonly error: string | null;
|
|
17
|
+
readonly deliver: string | null;
|
|
18
|
+
readonly validation: string | null;
|
|
19
|
+
readonly delivered: number;
|
|
20
|
+
readonly person: string;
|
|
21
|
+
readonly usage: string | null;
|
|
22
|
+
readonly started_at: string | null;
|
|
23
|
+
}
|
|
24
|
+
/** A refusal with an HTTP-like `status` (404: no such agent; 409: the key belongs to another agent). */
|
|
25
|
+
export declare class RunRefused extends Error {
|
|
26
|
+
readonly status: number;
|
|
27
|
+
constructor(status: number, message: string);
|
|
28
|
+
}
|
|
29
|
+
export interface EndOptions {
|
|
30
|
+
readonly output?: unknown;
|
|
31
|
+
readonly error?: string | null;
|
|
32
|
+
readonly validation?: ResultValidationRecord;
|
|
33
|
+
readonly usage?: RunUsage | null;
|
|
34
|
+
}
|
|
35
|
+
/** The ledger over a store's synchronous SQL (`SyncSql`: `UPDATE ... RETURNING` is what makes an end happen once). */
|
|
36
|
+
export declare function createRunLedger(sql: SyncSql): {
|
|
37
|
+
ensure: () => void;
|
|
38
|
+
get: (key: string) => RunRow | undefined;
|
|
39
|
+
/** Record a new running run. The caller has checked `get(key)` first. */
|
|
40
|
+
begin(run: {
|
|
41
|
+
key: string;
|
|
42
|
+
agent: string;
|
|
43
|
+
deliver: string | null;
|
|
44
|
+
person: unknown;
|
|
45
|
+
startedAt: string;
|
|
46
|
+
}): void;
|
|
47
|
+
/** End a run still running, once (a cancel and the run's own end race): false when something else ended it first. */
|
|
48
|
+
end(key: string, status: RunEnd, options?: EndOptions): boolean;
|
|
49
|
+
/** A completed run whose delivery-time check failed: its end becomes the failure (never after it was delivered). */
|
|
50
|
+
fail(key: string, status: RunEnd, error: string): void;
|
|
51
|
+
markDelivered(key: string): void;
|
|
52
|
+
/** Runs a stopped process left: still running (they end `interrupted`), or ended and not delivered. */
|
|
53
|
+
left(): RunRow[];
|
|
54
|
+
view(row: RunRow): RunView;
|
|
55
|
+
};
|
|
56
|
+
export type RunLedger = ReturnType<typeof createRunLedger>;
|
|
57
|
+
/** A started run's live handle: its conversation and first submission, and the card it answers. */
|
|
58
|
+
export interface RunExecution {
|
|
59
|
+
readonly conversation: ResultConversation & {
|
|
60
|
+
abort(context: Context): Promise<unknown>;
|
|
61
|
+
};
|
|
62
|
+
readonly submission: ResultSubmission;
|
|
63
|
+
readonly card: ResultCard;
|
|
64
|
+
}
|
|
65
|
+
export interface RunnerOptions {
|
|
66
|
+
readonly ledger: RunLedger;
|
|
67
|
+
/** The person whose store this is: written on each row so another process can deliver it as them. */
|
|
68
|
+
readonly person: unknown;
|
|
69
|
+
readonly context: Context;
|
|
70
|
+
/** Whether the home has this agent (a start of an unknown agent is refused 404 before any row is written). */
|
|
71
|
+
readonly has: (agent: string) => boolean | Promise<boolean>;
|
|
72
|
+
/** Open the run's conversation and submit its input: null when the agent is gone. */
|
|
73
|
+
readonly prepare: (agent: string, input: unknown) => Promise<RunExecution | null>;
|
|
74
|
+
/** Call an operation as the person: the result validators and the delivery (which may reach an `internal` operation). */
|
|
75
|
+
readonly invoke: ResultInvoke;
|
|
76
|
+
/** The run's model answers summed, from its conversation; null when none answered. */
|
|
77
|
+
readonly usage?: (conversation: RunExecution['conversation']) => Promise<RunUsage | null>;
|
|
78
|
+
/** The host's spend meter: throws `over_budget` when the app may not spend now. A refused run is never started and ends `error: over_budget`. */
|
|
79
|
+
readonly admit?: () => void;
|
|
80
|
+
readonly now?: () => string;
|
|
81
|
+
readonly deliverAttempts?: number;
|
|
82
|
+
readonly deliverBackoffMs?: number;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The runner of one person's store. A run lives in this process: one a restart cut short is found by `recover` and ends
|
|
86
|
+
* `interrupted`; one that ended and was not delivered is delivered then. Delivery is once (the host's receipt `deliver:<key>`).
|
|
87
|
+
*/
|
|
88
|
+
export declare function createRunner(options: RunnerOptions): {
|
|
89
|
+
/** Starts the run of this key (a key already started is the same run, whatever else is asked): launched and returned at once. */
|
|
90
|
+
start(agent: string, input: unknown, key: string, deliverTo: string | null): Promise<RunView>;
|
|
91
|
+
/** The run of this key as recorded, or null. Never waits on a delivery in flight: safe to call from inside one. */
|
|
92
|
+
status(key: string): Promise<RunView | null>;
|
|
93
|
+
/** Cancels it (its delivery gets `cancelled`); a run that has ended stays as it is; null: no such run. */
|
|
94
|
+
cancel(key: string): Promise<RunView | null>;
|
|
95
|
+
recover: () => Promise<void>;
|
|
96
|
+
};
|
|
97
|
+
export type Runner = ReturnType<typeof createRunner>;
|
|
98
|
+
//# sourceMappingURL=run-ledger.d.ts.map
|