@hachej/boring-agent 0.4.9 → 0.4.11

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
@@ -58,6 +58,25 @@ Limits: a run is admitted against its hold, not stopped mid-run (no per-call fen
58
58
 
59
59
  `createPresentTool({ workspace, resolveAccess?, maxBytes? })` from `@hachej/boring-agent/artifacts` returns the native `present(path)` tool (its workspace resolved per call like the guard's): the agent writes a workspace file with its ordinary file tools and presents it, and the chat shows a card that points at that file (`target`, the revision shown, no `id` or `ordinal`). The file's versions are the history of the workspace provider (`@hachej/boring-files/workspace`, whose `keep` retains the presented revision); a card of an older revision pins it and a card of the newest follows the file as it changes. The tool writes nothing and refuses files that are missing, not UTF-8 text or larger than 256 KiB. An opt-in `ack: { timeoutMs?, viewers? }` makes `present` wait until a page has mounted the presented revision: the page answers `{ kind: 'opened', instanceId, path, revision }` or `{ kind: 'refused', reason }` through the chat's `answer` (the transport's `?op=answer`), and the result carries `opened: true` with `viewer`, `opened: false` with `refused` (for example `closed by user`), `opened: 'unconfirmed'` after `timeoutMs` (default 15 s) or `opened: false, viewer: 'none'` at once when `viewers()` says no page watches the conversation. The wait is the durable wait of `ask_user` (`packages/agent/src/view-open.ts`, a conversation document keyed by the call); the deadline is stored, so a restart cannot extend it, and the first valid answer wins. Without `ack` the result is unchanged. `answerViewOpen` settles an answer; the host chains it with `answerUserQuestion` and `answerBrowserTask` in its `answer` handler. How to use it is in the tool's own description, so a host does not repeat it in its prompt: substantial output goes to a file that is then presented, short answers (and anything asked for in the chat) stay in the chat, and a revision re-reads the file, changes it at the same path and presents it again. A host adds only what is its own, such as what its HTML preview allows.
60
60
 
61
+ ### A view that is not a document (a dashboard)
62
+
63
+ A view the agent opens, such as a dashboard, is presented the same way: it is a small workspace file that names what to show (for example `sales.dashboard.json`), and the app registers an artifact kind that draws it. There is no second channel: the same `ack` answers for it. Mark the kind `waitsForReady: true` when its renderer loads asynchronously: the panel then reports the mount (and the page confirms the `present` call) only after the renderer calls `onReady` for the revision shown. `onFailed(reason)`, or a renderer that throws, refuses the call with that reason, and a renderer that never becomes ready leaves it `unconfirmed` at the deadline. A kind without `waitsForReady` is confirmed as soon as its file is read.
64
+
65
+ ```tsx
66
+ // Server: the agent writes sales.dashboard.json, then calls present('sales.dashboard.json').
67
+ createPresentTool({ workspace, ack: { timeoutMs: 15_000 } });
68
+ // -> { kind: 'presented', artifact, opened: true, viewer } | { opened: false, refused: 'the sales query failed' } | { opened: 'unconfirmed', timeoutMs }
69
+
70
+ // Page (pi-app block): the kind confirms the view only once its data has loaded.
71
+ const dashboard: ArtifactKind = {
72
+ match: target => target.resource.path.endsWith('.dashboard.json'),
73
+ label: 'Dashboard',
74
+ waitsForReady: true,
75
+ renderer: ({ text, onReady, onFailed }) => <SalesDashboard spec={JSON.parse(text)} onLoaded={onReady} onError={error => onFailed?.(error.message)} />,
76
+ };
77
+ <AgentWorkspace {...workspace} artifactKinds={[dashboard]} />;
78
+ ```
79
+
61
80
  `workspaceMentionReader(files, access, { maxReadBytes? })` is the reader for a host with a workspace provider: it reads the person's own published view through the provider (the access check the file viewers use) and has no containment check of its own. A host that has no provider has no reader here; it writes a `MentionReader` over whatever it does have.
62
81
 
63
82
  The descriptor's `id` and `ordinal` are optional and `present` sets neither; `parseArtifact` validates a descriptor and has no Pi runtime dependency (the copied `pi-chat` registry item carries the same validator for the browser).
@@ -66,6 +85,20 @@ The descriptor's `id` and `ordinal` are optional and `present` sets neither; `pa
66
85
 
67
86
  `refreshAgentState({ conversations, agent, read, write, context })` from `@hachej/boring-agent/agents` brings existing conversations up to a changed deployment. A conversation keeps the agent it was created with, so when the agent's `instructions` or default `thinkingLevel` change, the helper calls `configure` on each conversation. The host stores a hash of `[instructions, thinkingLevel]` (`read()` returns the stored hash, `write(hash)` stores the new one); it configures nothing when the hash is unchanged and writes the new hash last, so a failed run is retried. It returns how many conversations were configured.
68
87
 
88
+ ### Add an extension to an existing conversation
89
+
90
+ `addExtension(conversation, extension, context)` from `@hachej/boring-agent/agents` adds one Pi extension (for example `createApprovalExtension(...)` or one that registers a tool) to a conversation that already exists; its next turn runs with it. It edits the conversation's stored `pi.agent` selection in one native `Conversation.commit` (Pi's public `AgentDoc`): the name is appended to the conversation's own list, so it keeps its other extensions and picks up no other agent's, and the extension's tools are enabled and offered even when the conversation lists its tools. Adding it twice changes nothing; remove it with native `conversation.configure({ extensions: { remove: [extension] } }, context)`. Native `configure({ extensions: { add } })` is not the same: on a `defineAgent` conversation it replaces the stored list with an edit of the host default selection.
91
+
92
+ ```ts
93
+ const registry = createRegistry();
94
+ agent.install(registry);
95
+ registry.install(approval); // at every Harness open: the conversation stores the name only
96
+ const conversation = await harness.conversation(id, context);
97
+ await addExtension(conversation, approval, context);
98
+ ```
99
+
100
+ The selection is durable (it is in the conversation's Pi state and survives a reopen), but an extension is host code and is not stored: install it in the registry every time the Harness opens, or its name selects nothing. Installing it does not add it to other `defineAgent` conversations, whose lists are explicit; a conversation with no stored list (such as `harness.root()` without `agent`) follows the host default selection, which is every installed extension unless `HarnessSettings.extensions` names them.
101
+
69
102
  `createConvertingRead({ workspace?, resolveAccess?, convert?, cache?, maxBytes?, name? })` from `@hachej/boring-agent/file-convert` returns an extension that wraps Pi's native `read` (`wrapTool`); there is one `read`, and no second tool. Text goes to Pi's read untouched. A PDF, an office document or an image goes to the host's `convert({ name, mediaType, bytes })`, once per saved revision (cached by path and revision), and the converted text is paged the way Pi pages a text file: `offset` is the 1-based line to start at, `limit` a number of lines, a page ends at 2000 lines or 50 KB, and the note names the `offset` to continue from. What is text and what is not comes from one table, `fileKind` in `@hachej/boring-agent/file-types`, which looks at the extension and the leading bytes (Pi's read returns garbled text for a PDF, so a PDF is recognised by `%PDF-` even under another name); the mention resolver uses the same table. An archive or other binary file answers `unsupported` and is never converted; without `convert`, an image uses Pi's native behaviour and a PDF or office file answers `unsupported`. A line of converted text above 50 KB is split into 50 KB segments, each counted as one line, with a warning, so nothing is cut. The type is sniffed on the bytes the provider returns after its access check, and the converter is told the media type the file's signature names (a PDF named `.txt` arrives as `application/pdf`). The file is read through the workspace provider, so its access policy applies; a path the wrapper cannot place inside the workspace is left to the native read. Install it before `createFileGuard`, so the guard stays outermost and refuses anything outside the workspace first. `workersAiMarkdown(env.AI)` adapts a Workers AI `toMarkdown` binding to `convert` without a Cloudflare types dependency.
70
103
 
71
104
  ## Shared document readers
@@ -312,7 +345,18 @@ With self-evolution, workspace skills may be flat `.agent/skills/<name>.md` file
312
345
 
313
346
  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.
314
347
 
315
- **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.conversationId` 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.
348
+ **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.
349
+
350
+ **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`:
351
+
352
+ ```ts
353
+ const delegates = [{ agent: researcher, onChild: async (parent, child, tx) => {
354
+ const from = await tx.doc(HostIdentity, parent), to = await tx.doc(HostIdentity, child); // HostIdentity: the host's conversation doc
355
+ to.user = from.user; to.workspace = from.workspace; to.permissions = [...from.permissions];
356
+ } }];
357
+ ```
358
+
359
+ **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.
316
360
 
317
361
  **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.
318
362
 
package/dist/agents.d.ts CHANGED
@@ -9,8 +9,8 @@ export { createSkillsExtension, parseSkill } from './skills.js';
9
9
  export type { Skill } from './skills.js';
10
10
  export { jsonSchemaTool } from './json-schema-tool.js';
11
11
  export type { JsonObjectSchema, JsonSchemaToolOptions } from './json-schema-tool.js';
12
- export { createDelegateTool, DELEGATE_TOOL } from './delegate.js';
13
- export type { DelegateOptions } from './delegate.js';
12
+ export { createDelegateTool, DELEGATE_DETAILS_KIND, DELEGATE_TOOL } from './delegate.js';
13
+ export type { DelegateDetails, 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>`. */
@@ -82,4 +82,13 @@ export declare function refreshAgentState(options: {
82
82
  readonly write: (hash: string) => void | Promise<void>;
83
83
  readonly context: Context;
84
84
  }): Promise<number>;
85
+ /**
86
+ * Add one extension to an existing conversation, so its next turn runs with it (native `Conversation.commit` over the public
87
+ * `pi.agent` document, `AgentDoc`). The conversation stores extension names only: the host installs `extension` in the Harness
88
+ * registry, now and every time it opens the Harness, or the name selects nothing. Unlike `configure({ extensions: { add } })`,
89
+ * which replaces a stored list (a `defineAgent` conversation's) with an edit of the host default selection, this appends to the
90
+ * conversation's own list, so it keeps its other extensions and gains no one else's. Its tools are enabled and offered even when
91
+ * the conversation lists its tools explicitly. Adding an extension twice changes nothing. Remove it with native `configure`.
92
+ */
93
+ export declare function addExtension(conversation: Conversation, extension: Extension, context: Context): Promise<void>;
85
94
  //# sourceMappingURL=agents.d.ts.map
package/dist/agents.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { defineExtension } from '@earendil-works/pi-durable';
2
+ 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
6
  export { createSkillsExtension, parseSkill } from './skills.js';
7
7
  export { jsonSchemaTool } from './json-schema-tool.js';
8
- export { createDelegateTool, DELEGATE_TOOL } from './delegate.js';
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. */
10
10
  export function defineAgent(spec) {
11
11
  if (!NAME.test(spec.id))
@@ -96,3 +96,35 @@ export async function refreshAgentState(options) {
96
96
  await options.write(hash);
97
97
  return configured;
98
98
  }
99
+ /**
100
+ * Add one extension to an existing conversation, so its next turn runs with it (native `Conversation.commit` over the public
101
+ * `pi.agent` document, `AgentDoc`). The conversation stores extension names only: the host installs `extension` in the Harness
102
+ * registry, now and every time it opens the Harness, or the name selects nothing. Unlike `configure({ extensions: { add } })`,
103
+ * which replaces a stored list (a `defineAgent` conversation's) with an edit of the host default selection, this appends to the
104
+ * conversation's own list, so it keeps its other extensions and gains no one else's. Its tools are enabled and offered even when
105
+ * the conversation lists its tools explicitly. Adding an extension twice changes nothing. Remove it with native `configure`.
106
+ */
107
+ export async function addExtension(conversation, extension, context) {
108
+ const tools = (extension.tools ?? []).map(tool => tool.name);
109
+ await conversation.commit(async (tx) => {
110
+ const state = await tx.doc(AgentDoc, conversation.id);
111
+ const append = (list, names) => { for (const name of names)
112
+ if (!list.includes(name))
113
+ list.push(name); };
114
+ const selected = state.extensions;
115
+ if (Array.isArray(selected))
116
+ append(selected, [extension.name]);
117
+ else {
118
+ const add = [...selected?.add ?? []];
119
+ append(add, [extension.name]);
120
+ const remove = (selected?.remove ?? []).filter(name => name !== extension.name);
121
+ state.extensions = { add, ...(remove.length ? { remove } : {}) };
122
+ }
123
+ if (Array.isArray(state.tools))
124
+ append(state.tools, tools);
125
+ else if (state.tools)
126
+ state.tools = { remove: state.tools.remove.filter(name => !tools.includes(name)) };
127
+ if (Array.isArray(state.modelTools))
128
+ append(state.modelTools, tools);
129
+ }, context);
130
+ }
@@ -1,5 +1,36 @@
1
- import type { AgentChange, ToolRegistration } from '@earendil-works/pi-durable';
1
+ import type { AgentChange, ConversationId, ToolRegistration, Tx } from '@earendil-works/pi-durable';
2
2
  export declare const DELEGATE_TOOL = "delegate";
3
+ /** `DelegateDetails.kind`: how a UI recognises a sub-agent call's details, whatever the tool is named. */
4
+ export declare const DELEGATE_DETAILS_KIND = "boring.delegate";
5
+ export type DelegateStatus = 'running' | 'done' | 'failed' | 'cancelled';
6
+ /**
7
+ * What a delegate call publishes for a UI (a sub-agent card), version 1. While the child runs, the call's running details
8
+ * (Pi's `ToolExecutionApi.details`, `pi.live` tool slot) carry it with `status: 'running'`, replaced each time the child's tool
9
+ * calls or tokens change; the tool result's details carry the finished value (`done`, `failed` with `error`, or `cancelled`).
10
+ * Fields are only added under version 1; a change of meaning bumps `version`.
11
+ */
12
+ export type DelegateDetails = {
13
+ readonly kind: typeof DELEGATE_DETAILS_KIND;
14
+ readonly version: 1;
15
+ /** The child agent's id (`DelegateOptions.agent.id`). */
16
+ readonly agent: string;
17
+ /** The child conversation, owned by the delegate call's tool task. */
18
+ readonly conversationId: ConversationId;
19
+ readonly status: DelegateStatus;
20
+ /** Epoch milliseconds the call first started (kept across a replay). */
21
+ readonly startedAt: number;
22
+ /** Milliseconds from `startedAt` to this update (to the end, once finished). */
23
+ readonly durationMs: number;
24
+ /** Tool calls the child's model has made so far (from its committed assistant entries). */
25
+ readonly toolCalls: number;
26
+ /** The child's spend so far, from its `pi.usage`: prompt tokens (cache reads and writes included) and output tokens. */
27
+ readonly tokens: {
28
+ readonly input: number;
29
+ readonly output: number;
30
+ };
31
+ /** Why the child failed (`failed` only). */
32
+ readonly error?: string;
33
+ };
3
34
  export interface DelegateOptions {
4
35
  /**
5
36
  * The child agent, usually a `defineAgent` result (its `id` and native `agent` change). The child conversation selects exactly its
@@ -12,6 +43,12 @@ export interface DelegateOptions {
12
43
  /** Tool name; default `delegate`. Give each delegate of one agent its own name. */
13
44
  readonly name?: string;
14
45
  readonly description?: string;
46
+ /**
47
+ * Runs inside the native commit (`ToolExecutionApi.commit`, Pi's `Tx`) that creates the child, after the child agent is
48
+ * configured: the host copies what the child must carry from the parent (user, workspace binding, permissions) with `tx`.
49
+ * It runs once per child (never on a replay that finds the existing child); a throw aborts the commit, so no child exists.
50
+ */
51
+ readonly onChild?: (parent: ConversationId, child: ConversationId, tx: Tx) => void | Promise<void>;
15
52
  }
16
53
  /**
17
54
  * Pi's subagent pattern (pi-durable README "Abort and Subagents") as a tool factory: the child is a conversation owned by the
@@ -19,7 +56,8 @@ export interface DelegateOptions {
19
56
  * once the child is. Replay-safe: a rerun of the same tool task (after a crash or eviction) finds its child through the native
20
57
  * ownership index and its submission through the request id, so it never starts a second child or a second turn. Returns only the
21
58
  * 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
22
- * sandbox stays last), so a delegate cannot escape the parent's gate; `details.conversationId` names the child for a UI that shows it.
59
+ * 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
+ * `onChild` lets the host write the parent's identity onto the child in the same commit that creates it.
23
61
  */
24
62
  export declare function createDelegateTool(options: DelegateOptions): ToolRegistration;
25
63
  //# sourceMappingURL=delegate.d.ts.map
package/dist/delegate.js CHANGED
@@ -1,15 +1,18 @@
1
- import { AssistantEntry, configure, defineTool } from '@earendil-works/pi-durable';
1
+ import { 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);
5
5
  export const DELEGATE_TOOL = 'delegate';
6
+ /** `DelegateDetails.kind`: how a UI recognises a sub-agent call's details, whatever the tool is named. */
7
+ export const DELEGATE_DETAILS_KIND = 'boring.delegate';
6
8
  /**
7
9
  * Pi's subagent pattern (pi-durable README "Abort and Subagents") as a tool factory: the child is a conversation owned by the
8
10
  * calling tool task (`ownership: { kind: 'task', taskId }`), so aborting the call aborts the child and the parent is idle only
9
11
  * once the child is. Replay-safe: a rerun of the same tool task (after a crash or eviction) finds its child through the native
10
12
  * ownership index and its submission through the request id, so it never starts a second child or a second turn. Returns only the
11
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
12
- * sandbox stays last), so a delegate cannot escape the parent's gate; `details.conversationId` names the child for a UI that shows it.
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
+ * `onChild` lets the host write the parent's identity onto the child in the same commit that creates it.
13
16
  */
14
17
  export function createDelegateTool(options) {
15
18
  const name = options.name ?? DELEGATE_TOOL;
@@ -30,23 +33,109 @@ export function createDelegateTool(options) {
30
33
  return existing.id;
31
34
  const created = await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } });
32
35
  await configure(tx, created.id, agent);
36
+ await options.onChild?.(api.conversationId, created.id, tx);
33
37
  return created.id;
34
38
  }, context);
35
- await api.details({ conversationId: childId }, context);
39
+ const startedAt = await api.memo('delegate.startedAt', Date.now(), context);
40
+ const progress = childProgress(api, childId);
41
+ const details = (status, counts, error) => ({
42
+ kind: DELEGATE_DETAILS_KIND, version: 1, agent: options.agent.id, conversationId: childId, status, startedAt,
43
+ durationMs: Math.max(1, Date.now() - startedAt), ...counts, ...(error === undefined ? {} : { error })
44
+ });
45
+ const initial = await progress.read(context);
46
+ await api.details(details('running', initial), context);
36
47
  const conversation = await api.conversation(childId, context);
37
48
  if (conversation === undefined)
38
49
  throw new Error(`Delegate conversation ${childId} is missing`);
39
50
  const submission = await conversation.submit({ type: 'input', content: args.task, requestId: `${name}:${api.taskId}` }, context);
40
- const settled = await submission.wait(context);
41
- if (settled.status !== 'done' || settled.type !== 'input')
42
- throw new Error(`The ${options.agent.id} agent did not answer: ${settled.status}`);
51
+ const live = await publishLive(api, childId, progress, initial, counts => api.details(details('running', counts), context), context);
52
+ // A stopped call ends here: Pi writes its aborted result from the slot, so its details stay the last `running` value.
53
+ const settled = await submission.wait(context).finally(() => live.stop());
54
+ const counts = await progress.read(context);
55
+ if (settled.status !== 'done' || settled.type !== 'input') {
56
+ const reason = settled.status === 'unanswered' ? settled.reason : settled.status;
57
+ const detail = settled.status === 'unanswered' && typeof settled.detail === 'string' && settled.detail ? settled.detail : undefined;
58
+ const error = `The ${options.agent.id} agent did not answer: ${reason}${detail ? ` (${detail})` : ''}`;
59
+ return { output: [{ type: 'text', text: error }], isError: true, details: reason === 'aborted' ? details('cancelled', counts) : details('failed', counts, error) };
60
+ }
43
61
  const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), context);
44
62
  const message = answer?.model?.find(part => part.role === 'assistant');
45
63
  const text = message?.role === 'assistant' ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\n') : '';
46
- return { output: [{ type: 'text', text }], details: { conversationId: childId } };
64
+ return { output: [{ type: 'text', text }], details: details('done', counts) };
47
65
  },
48
66
  });
49
67
  }
68
+ /**
69
+ * The child's counts from its native records: tool calls from its committed assistant entries (scanned once, incrementally,
70
+ * through the invocation's commit), tokens from its `pi.usage` (Pi's `UsageDoc`, both buckets).
71
+ */
72
+ function childProgress(api, childId) {
73
+ let toolCalls = 0, after;
74
+ return {
75
+ async read(context) {
76
+ const scanned = await api.commit(async (tx) => {
77
+ let found = 0, last = after, cursor;
78
+ do {
79
+ const page = await tx.scanEntries({ conversationId: childId, order: 'ascending', ...(last === undefined ? {} : { minEntryId: (last + 1) }) }, 100, cursor);
80
+ for (const entry of page.items) {
81
+ last = entry.id;
82
+ for (const message of entry.model ?? [])
83
+ if (message.role === 'assistant')
84
+ found += message.content.filter(part => part.type === 'toolCall').length;
85
+ }
86
+ cursor = page.next;
87
+ } while (cursor !== undefined);
88
+ return { found, last };
89
+ }, context);
90
+ toolCalls += scanned.found;
91
+ after = scanned.last;
92
+ const usage = await api.snapshot(UsageDoc, childId, context);
93
+ const tokens = { input: 0, output: 0 };
94
+ for (const bucket of [usage?.models, usage?.tools])
95
+ for (const spend of Object.values(bucket ?? {})) {
96
+ tokens.input += (spend.input ?? 0) + (spend.cacheRead ?? 0) + (spend.cacheWrite ?? 0);
97
+ tokens.output += spend.output ?? 0;
98
+ }
99
+ return { toolCalls, tokens };
100
+ },
101
+ };
102
+ }
103
+ /**
104
+ * Publish the child's counts while it runs. Pi's `watchDoc(LiveDoc)` on the child is the trigger: a frame whose run shape changed
105
+ * (a response committed, a tool round started or ended; never a streamed partial) schedules one read of the counts, outside the
106
+ * listener so the watch never waits on it, coalescing the frames that arrive meanwhile. The details are replaced only when the
107
+ * counts changed. `stop()` ends the watch and waits for a publish in flight.
108
+ */
109
+ async function publishLive(api, childId, progress, initial, publish, context) {
110
+ const watch = await api.watchDoc(LiveDoc, childId, context);
111
+ if (watch === undefined)
112
+ return { stop: async () => undefined };
113
+ let shape = '', published = JSON.stringify(initial), stopped = false, dirty = false;
114
+ let reading, committing = Promise.resolve();
115
+ const drain = async () => {
116
+ while (dirty && !stopped) {
117
+ dirty = false;
118
+ const counts = await progress.read(context);
119
+ const key = JSON.stringify(counts);
120
+ if (stopped || key === published)
121
+ continue;
122
+ published = key;
123
+ // Not awaited: Pi paces running-details commits (`progress.outputIntervalMs`) and a newer value replaces a pending one.
124
+ committing = publish(counts).catch(() => undefined);
125
+ }
126
+ };
127
+ watch.start(async (value) => {
128
+ const next = runShape(value);
129
+ if (stopped || next === shape)
130
+ return;
131
+ shape = next;
132
+ dirty = true;
133
+ reading ??= drain().catch(() => undefined).finally(() => { reading = undefined; });
134
+ });
135
+ return { async stop() { stopped = true; await watch.stop(); await reading; await committing; } };
136
+ }
137
+ const runShape = (live) => JSON.stringify([live?.run?.taskId ?? null, live?.generation?.attempt ?? null, live?.generation?.message === undefined,
138
+ (live?.tools ?? []).map(slot => `${slot.callId}:${slot.status}`)]);
50
139
  /** `change` with the parent's guard extensions appended to its extension selection (an array or `add`; an unset selection already inherits the parent's). */
51
140
  function withGuards(change, guards) {
52
141
  const { extensions } = change;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -156,8 +156,8 @@
156
156
  "@earendil-works/pi-durable": "~1.2.0",
157
157
  "@earendil-works/pi-ai": "~1.2.0",
158
158
  "@earendil-works/chord": "~1.2.0",
159
- "@hachej/boring-files": "^0.4.9",
160
- "@hachej/boring-ui-kit": "^0.4.9",
159
+ "@hachej/boring-files": "^0.4.11",
160
+ "@hachej/boring-ui-kit": "^0.4.11",
161
161
  "xlsx": "^0.20.3"
162
162
  },
163
163
  "peerDependenciesMeta": {