@hachej/boring-agent 0.4.0 → 0.4.1

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
@@ -136,6 +136,28 @@ Failure is fail-open and Pi's: a throwing `context` is reported through `Harness
136
136
 
137
137
  Screen context: a browser can send what the person is looking at with each submit. `createRemoteChat({ requestContext })` adds the returned plain object to the submit body as `context`. In `ChatTransportAccess`, `requestContext(value)` receives it after validation and before `conversation.submit`; a value that is not a plain JSON object of at most 4 KB is `400 invalid-request`, and a host without `requestContext` ignores the field. The host stores `context` by conversation id in `requestContext` and renders it in `createPromptContext({ context })`; the transport keeps nothing. With `useRemoteChat` the context is the `requestContext` value as it is (`name`, `params`: a host written before placement keeps working) plus `placement` (`docked` or `floating`), reported by `PiChat` / `AmbientChat`; `PiChat` takes a `placement` prop for a host that floats it, as the `AgentLayer` does. `renderRequestContext(value)` (from `@hachej/boring-agent/prompt-context`) turns it into the agent's role and the screen for `createPromptContext`; placement changes behaviour, never authority (see [PAGE-TOOLS.md](../../docs/architecture/PAGE-TOOLS.md)). Tests: `test/packages/agent-chat-transport-context.test.mjs`, `test/packages/ui-remote-chat-context.test.mjs`, `test/packages/agent-prompt-context.test.mjs`, `test/contracts/chat-placement.test.mjs`, `test/contracts/chat-placement-registry.test.mjs`.
138
138
 
139
+ ## Chat over a hibernatable WebSocket
140
+
141
+ `createConversationSocketHandler` (from `@hachej/boring-agent/chat-transport`) serves the chat transport over WebSockets accepted with the Durable Object Hibernation API, so an idle open page does not keep the object in memory (or billed). One socket carries the watch (the same `view` then `ops` frames as the NDJSON stream) and every call: `{"kind":"call","id","op","params","body"}` is answered by `{"kind":"reply","id","status","body"}` and runs through `createChatTransportHandler` unchanged (validation, `allow`, `project`, `submission-refused`, request-id deduplication). A submit without `whenBusy` is a follow-up when the conversation is busy. `ping` is answered `pong` (on Cloudflare by `setWebSocketAutoResponse`, without waking the object).
142
+
143
+ Mount it in a Durable Object (any host with the same `acceptWebSocket`/`getWebSockets` and attachment methods works the same):
144
+
145
+ ```js
146
+ sockets = createConversationSocketHandler({ state: this.ctx,
147
+ // Upgrade only: who and which conversation, kept in the socket's attachment (`serializeAttachment`). Null answers 401.
148
+ authenticate: async request => ({ owner: personOf(request), conversationId: selectedIn(request) }),
149
+ // From the attachment alone, at accept and after every wake: the same access the HTTP handler gets.
150
+ access: async ({ owner, conversationId }) => ({ conversation: await open(owner, conversationId), context }) });
151
+ fetch(request) { if (request.headers.get('upgrade') === 'websocket') return this.sockets.fetch(request); /* ... */ }
152
+ webSocketMessage(socket, message) { return this.sockets.webSocketMessage(socket, message); }
153
+ webSocketClose(socket, code, reason) { return this.sockets.webSocketClose(socket, code, reason); }
154
+ alarm() { void this.sockets.resume(); /* ... */ } // or Lifecycle's host `onStart`
155
+ ```
156
+
157
+ After a wake the in-memory watches are gone; `resume()` (also run by every message and upgrade) reopens each socket's watch from its attachment and sends a complete view first. `broadcast(event, { owner?, conversationId? })` sends host events (`{"kind":"event","event"}`: presence, the conversation list) on the same sockets, and `connections({ owner?, conversationId? })` lists the open sockets' attachments. A browser cannot set headers on a WebSocket: a client offers `boring.chat` as subprotocol (the handler selects it) and a bearer host reads its credential from another offered value, as the Cloudflare recipe does. Pi keeps idle documents and contexts in memory on ten-minute timers by default, and a pending timer keeps a Durable Object from hibernating: open the harness with `settings: { documentRetentionMs: 0, contextRetentionMs: 0 }`.
158
+
159
+ The client is `createRemoteChat({ transport: 'websocket' })` (or `useRemoteChat({ transport: 'websocket' })`): the controller API is unchanged; unanswered calls are sent again unchanged (same request ID) on the next socket, each socket starts from a complete view, and if the first socket cannot open it uses HTTP. Reused from Cloudflare: the platform Hibernation API itself; the agents SDK `WebSockets` capability (`agents/websockets`) was not used because it is a Lifecycle catch-all that claims every upgrade and speaks the Agent protocol, while this handler must mount in a plain Durable Object too. `PiHarness` leaves "how a session reaches a client" to the host. Tests: `test/packages/agent-chat-socket.test.mjs`; the recipe journey `examples/hosts/cloudflare/journey.mjs` (`CF_HIBERNATION=1` proves hibernation with the socket open).
160
+
139
161
  ## Native questions
140
162
 
141
163
  `createQuestions` from `@hachej/boring-agent/questions` exposes a native task, session document family and extension. Install the extension explicitly. Call `admit(tx, question, ownership, context)` in a native commit to persist a single-choice question and its task together. `resolve` and `consume` take the caller's native transaction so host writes can share that atomic commit. Call them before host table writes: native task reads after a table write are unavailable and the operation returns conflict. Host authentication and current subject/policy checks run at both boundaries. A resolved answer is clarification, never publication permission.
@@ -238,7 +260,7 @@ Each evidence entry must link to a completed native ToolTask, its original assis
238
260
 
239
261
  ## Asking the person
240
262
 
241
- `@hachej/boring-agent/ask-user` exports `createAskUserTool()`, a replay-safe native tool named `ask_user` with arguments `{ question, options?: string[2..6], allowFreeText? }`. The call waits on a conversation document keyed by its assistant entry and tool call ID, so no model request is held, a pending question survives a restart and stopping the conversation cancels it. A trusted host resolves it with `answerUserQuestion(conversation, askUserQuestionId(assistantEntryId, callId), answer)` (pass the scoped ID through `ChatTransportAccess.answer` after authenticating the person): the answer must be an option unless free text is allowed, at most 2000 characters; one answer wins, repeating it is idempotent. The tool result is `{"kind":"answered","answer":"..."}`. The chat transport also serves `?op=answer` and `?op=withdraw` (a queued message, by submission ID), both gated by `allow`. Like browser tasks and `requireApproval`-gated tools, it is `callers: ['model']`: the wait binds to the calling model entry, so Pi refuses a nested `api.executeTool` call (`tool_unavailable`) until nested bindings are supported.
263
+ `@hachej/boring-agent/ask-user` exports `createAskUserTool()`, a replay-safe native tool named `ask_user` with arguments `{ question, options?: string[2..6], allowFreeText?, questions?: { question, options? }[1..8] }`. Free text is accepted unless `allowFreeText` is `false`; `questions` makes one form answered as one free text that lists each question and its answer (the `pi-chat` question card renders it). The call waits on a conversation document keyed by its assistant entry and tool call ID, so no model request is held, a pending question survives a restart and stopping the conversation cancels it. A trusted host resolves it with `answerUserQuestion(conversation, askUserQuestionId(assistantEntryId, callId), answer)` (pass the scoped ID through `ChatTransportAccess.answer` after authenticating the person): the answer must be an option when `allowFreeText` is `false`, at most 2000 characters; one answer wins, repeating it is idempotent. The tool result is `{"kind":"answered","answer":"..."}`. The chat transport also serves `?op=answer` and `?op=withdraw` (a queued message, by submission ID), both gated by `allow`. Like browser tasks and `requireApproval`-gated tools, it is `callers: ['model']`: the wait binds to the calling model entry, so Pi refuses a nested `api.executeTool` call (`tool_unavailable`) until nested bindings are supported.
242
264
 
243
265
  ## Browser tasks
244
266
 
@@ -248,6 +270,16 @@ Each evidence entry must link to a completed native ToolTask, its original assis
248
270
 
249
271
  `createSkillsExtension` 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.
250
272
 
273
+ ## Sub-agents and schedules
274
+
275
+ 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.
276
+
277
+ **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.
278
+
279
+ **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.
280
+
281
+ **Storage conformance.** Boring ships no Pi `Storage` implementation. `test/packages/pi-storage-conformance.test.mjs` runs Pi's own suite (`createStorageConformance`, what `registerStorageConformance` registers with Vitest) on every storage the repository configures: `openPiStorage` (shared/pi-storage.mjs) on the local-disk and network-file-system presets, Pi's `SqliteStorage` over `openBrowserSqlite`, and Pi's `openDurableObjectSqliteStorage` on a SQLite-backed Durable Object in workerd through miniflare (the version the pinned wrangler resolves).
282
+
251
283
  ## Tools from a JSON Schema
252
284
 
253
285
  `jsonSchemaTool({ name, description, parameters, execute, ... })` from `@hachej/boring-agent/agents` makes a native tool registration from a raw JSON Schema (an MCP server's `inputSchema`, an agent-written `.agent/tools/*.json`) with typebox's `Type.Unsafe`, so no `as unknown as TSchema` cast is needed. It refuses a schema that is not `"type": "object"` and adds an empty `properties` when there is none. It validates nothing itself: the Harness already validates every call's arguments against `parameters` before `execute` (Pi's `validateToolArguments`, which compiles plain JSON Schema with typebox) and answers a mismatch with an `invalid_arguments` error result naming the fields, before an approval gate is asked. The studio's MCP recipe and self-evolution use it; their journeys send one call with a missing required argument each.
package/dist/agents.d.ts CHANGED
@@ -2,12 +2,15 @@ import type { AgentChange, Conversation, ConversationCreateOptions, Extension, H
2
2
  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
+ import type { DelegateOptions } from './delegate.js';
5
6
  import type { Skill } from './skills.js';
6
7
  export type { SelfEvolutionError, SelfEvolutionReport } from './self-evolving.js';
7
8
  export { createSkillsExtension, parseSkill } from './skills.js';
8
9
  export type { Skill } from './skills.js';
9
10
  export { jsonSchemaTool } from './json-schema-tool.js';
10
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';
11
14
  /** One agent as plain data over native Pi pieces. It is a convenience, not another registry or lifecycle. */
12
15
  export interface AgentSpec {
13
16
  /** Stable name; it becomes the native extension name `agent.<id>`. */
@@ -20,6 +23,13 @@ export interface AgentSpec {
20
23
  /** Additional native extensions this agent selects, for example Pi's `CodingTools`. */
21
24
  readonly extensions?: readonly Extension[];
22
25
  readonly skills?: readonly Skill[];
26
+ /**
27
+ * Sub-agents this agent may hand a task to: each becomes a `delegate` tool (`createDelegateTool`) among this agent's tools, and
28
+ * `install` installs the child agent's extensions too. The child runs in a conversation owned by the tool call, with only its own tools.
29
+ */
30
+ readonly delegates?: readonly (DelegateOptions & {
31
+ readonly agent: DefinedAgent;
32
+ })[];
23
33
  /** Directory within the harness environment. */
24
34
  readonly cwd?: string;
25
35
  /**
package/dist/agents.js CHANGED
@@ -2,8 +2,10 @@ import { createHash } from 'node:crypto';
2
2
  import { 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
+ import { createDelegateTool } from './delegate.js';
5
6
  export { createSkillsExtension, parseSkill } from './skills.js';
6
7
  export { jsonSchemaTool } from './json-schema-tool.js';
8
+ export { createDelegateTool, DELEGATE_TOOL } from './delegate.js';
7
9
  /** Turn an agent described as data into native extensions and a native agent configuration. */
8
10
  export function defineAgent(spec) {
9
11
  if (!NAME.test(spec.id))
@@ -13,16 +15,17 @@ export function defineAgent(spec) {
13
15
  // Self-evolving (SELF-3): Pi renders native `instructions` after every extension section, so the host's instructions become the first
14
16
  // section of the agent's own (first) extension instead, and the agent-written section, in the last extension, comes after them.
15
17
  const base = spec.selfEvolving && spec.instructions !== undefined ? spec.instructions : undefined;
18
+ const tools = [...spec.tools ?? [], ...(spec.delegates ?? []).map(createDelegateTool)];
16
19
  const own = [];
17
- if (spec.tools?.length || base !== undefined) {
18
- own.push(defineExtension({ name: `agent.${spec.id}`, tools: [...spec.tools ?? []], sections: base === undefined ? [] : [{ key: 'host-instructions', render: () => base }] }));
20
+ if (tools.length || base !== undefined) {
21
+ own.push(defineExtension({ name: `agent.${spec.id}`, tools, sections: base === undefined ? [] : [{ key: 'host-instructions', render: () => base }] }));
19
22
  }
20
23
  // The skills of a self-evolving agent live in its per-workspace extension, so agent-written skills join them on reload. Without a
21
24
  // workspace (a self-evolving agent whose workspace is chosen per conversation, before `inWorkspace`) they stay the host's alone.
22
25
  const evolving = spec.selfEvolving === true && spec.workspace !== undefined;
23
26
  const hostSkills = spec.skills?.length && !evolving ? [createSkillsExtension(`agent.${spec.id}.skills`, spec.skills)] : [];
24
27
  const listed = (skills) => Object.freeze(skills.map(skill => Object.freeze({ name: skill.name, description: skill.description.trim() })));
25
- const reserved = [...(spec.tools ?? []), ...(spec.extensions ?? []).flatMap(extension => extension.tools ?? [])].map(tool => tool.name);
28
+ const reserved = [...tools, ...(spec.extensions ?? []).flatMap(extension => extension.tools ?? [])].map(tool => tool.name);
26
29
  /** The agent as one native configuration: its own extensions, the host's, and with `evolution` the per-workspace one last. */
27
30
  function assemble(selected, evolution, inWorkspace) {
28
31
  const initial = evolution?.current();
@@ -39,12 +42,16 @@ export function defineAgent(spec) {
39
42
  return Object.freeze({
40
43
  id: spec.id, extensions, agent,
41
44
  get skills() { return evolution ? listed(evolution.skills()) : fixedSkills; },
42
- install: (registry) => { for (const extension of extensions) {
43
- if (extension === initial)
44
- evolution.install(registry);
45
- else
46
- registry.install(extension);
47
- } },
45
+ install: (registry) => {
46
+ for (const delegate of spec.delegates ?? [])
47
+ delegate.agent.install(registry);
48
+ for (const extension of extensions) {
49
+ if (extension === initial)
50
+ evolution.install(registry);
51
+ else
52
+ registry.install(extension);
53
+ }
54
+ },
48
55
  ...(evolution ? { reload: evolution.reload } : {}),
49
56
  createConversation: (harness, context, options = {}) => harness.createConversation({ ownership: options.ownership ?? { kind: 'ownerless' }, agent, ...(options.init ? { init: options.init } : {}) }, context),
50
57
  ...(inWorkspace ? { inWorkspace } : {}),
package/dist/ask-user.js CHANGED
@@ -15,8 +15,8 @@ function refusal(options, allowFreeText) {
15
15
  if (options !== undefined && (options.length < 2 || options.length > 6 || options.some(option => typeof option !== 'string' || !option.trim() || option.length > 200)
16
16
  || new Set(options).size !== options.length))
17
17
  return 'options must be 2 to 6 distinct short strings';
18
- if (options === undefined && allowFreeText !== true)
19
- return 'give options, or set allowFreeText to true';
18
+ if (options === undefined && allowFreeText === false)
19
+ return 'give options, or allow free text';
20
20
  return undefined;
21
21
  }
22
22
  /**
@@ -28,20 +28,27 @@ function refusal(options, allowFreeText) {
28
28
  export function createAskUserTool(options = {}) {
29
29
  return defineTool({
30
30
  name: ASK_USER_TOOL,
31
- description: options.description ?? 'Ask the person a question and wait for their answer. Give 2 to 6 short options, or allow free text, or both.',
31
+ description: options.description ?? 'Ask the person a question and wait for their answer. Give 2 to 6 short options when the answer is a choice; the person may also answer in their own words unless allowFreeText is false. To ask several related questions at once, give them as `questions` (up to 8, each with its own options): the person answers them in one form, may skip some, and the answer lists each question with its answer.',
32
32
  parameters: Type.Object({
33
33
  question: Type.String({ minLength: 1, maxLength: 1000 }),
34
34
  options: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { minItems: 2, maxItems: 6 })),
35
35
  allowFreeText: Type.Optional(Type.Boolean()),
36
+ questions: Type.Optional(Type.Array(Type.Object({
37
+ question: Type.String({ minLength: 1, maxLength: 1000 }),
38
+ options: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { minItems: 2, maxItems: 6 })),
39
+ }, { additionalProperties: false }), { minItems: 1, maxItems: 8 })),
36
40
  }, { additionalProperties: false }),
37
41
  replay: 'safe',
38
42
  // The wait binds to the calling model entry and call id; a nested `api.executeTool` caller has neither (see durable-wait.ts).
39
43
  callers: ['model'],
40
44
  execute: async (args, api, context) => {
41
- const problem = refusal(args.options, args.allowFreeText);
45
+ // A form (`questions`) is answered as one free text that lists each question and its answer.
46
+ const form = args.questions?.length ? args.questions : undefined;
47
+ const problem = form ? form.map(item => refusal(item.options, true)).find(Boolean) : refusal(args.options, args.allowFreeText);
42
48
  if (problem)
43
49
  return { isError: true, output: [{ type: 'text', text: `Invalid question: ${problem}.` }] };
44
- const answer = await askPerson(api, context, { prompt: args.question, ...(args.options === undefined ? {} : { options: args.options }), allowFreeText: args.allowFreeText === true });
50
+ const prompt = form ? [args.question, ...form.map((item, i) => `${i + 1}. ${item.question}`)].join('\n') : args.question;
51
+ const answer = await askPerson(api, context, { prompt, ...(form || args.options === undefined ? {} : { options: args.options }), allowFreeText: form ? true : args.allowFreeText !== false });
45
52
  return { output: [{ type: 'text', text: JSON.stringify({ kind: 'answered', answer }) }] };
46
53
  },
47
54
  });
@@ -85,4 +85,81 @@ export declare function createChatTransportHandler(options: ChatTransportOptions
85
85
  * idempotent on `requestId`, as native submission is.
86
86
  */
87
87
  export declare function routeSubmissions(conversation: Conversation, admit: (draft: InputSubmissionDraft, context: Context) => Promise<unknown>): Conversation;
88
+ /**
89
+ * A WebSocket accepted with the Durable Object Hibernation API (`ctx.acceptWebSocket`): the members this handler uses. Any host
90
+ * with the same shape can mount it (tests use an in-memory one).
91
+ */
92
+ export interface HibernatableSocket {
93
+ send(message: string): void;
94
+ close(code?: number, reason?: string): void;
95
+ serializeAttachment(value: unknown): void;
96
+ deserializeAttachment(): unknown;
97
+ }
98
+ /** The Hibernation API of a Durable Object's `ctx` (`DurableObjectState`) that the handler uses. */
99
+ export interface HibernationState {
100
+ acceptWebSocket(socket: HibernatableSocket, tags?: string[]): void;
101
+ getWebSockets(tag?: string): HibernatableSocket[];
102
+ setWebSocketAutoResponse?(pair?: unknown): void;
103
+ }
104
+ /**
105
+ * Per-socket state, kept in the socket's attachment so it survives hibernation. `owner` and `conversationId` are the host's
106
+ * (who the socket acts for, which conversation it watches); further JSON fields are the host's to add. `socketId` is the handler's.
107
+ */
108
+ export interface ConversationSocketAttachment {
109
+ readonly owner: string;
110
+ readonly conversationId: string;
111
+ readonly [key: string]: unknown;
112
+ }
113
+ export interface ConversationSocketOptions {
114
+ /** The Durable Object's `ctx`, or any object with the same Hibernation API. */
115
+ readonly state: HibernationState;
116
+ /**
117
+ * Authenticate the upgrade request and select the conversation, in trusted host code (also the Origin policy: a browser sends
118
+ * cookies with a cross-site upgrade). Null refuses with 401 before any socket is accepted. The result becomes the attachment.
119
+ */
120
+ readonly authenticate: (request: Request) => Promise<ConversationSocketAttachment | null>;
121
+ /**
122
+ * The conversation access for a socket, from its attachment alone: called when the socket is accepted and again after every
123
+ * hibernation (the original request is gone by then). The same access the HTTP handler gets from `authenticate`. Null closes the socket.
124
+ */
125
+ readonly access: (attachment: ConversationSocketAttachment) => Promise<ChatTransportAccess | null>;
126
+ }
127
+ /** The subprotocol a browser client offers and the handler selects. Other offered values (for example a credential) are the host's. */
128
+ export declare const CHAT_SOCKET_PROTOCOL = "boring.chat";
129
+ export interface ConversationSocketHandler {
130
+ /** Cloudflare upgrade: authenticate, create the `WebSocketPair`, accept the server end and answer 101 (401 when refused). */
131
+ readonly fetch: (request: Request) => Promise<Response>;
132
+ /** Accept a socket the host created: tag it with its owner and conversation, store the attachment and send the first view. False when refused. */
133
+ readonly accept: (socket: HibernatableSocket, request: Request) => Promise<boolean>;
134
+ /** The Durable Object's `webSocketMessage`. */
135
+ readonly webSocketMessage: (socket: HibernatableSocket, message: string | ArrayBuffer) => Promise<void>;
136
+ /** The Durable Object's `webSocketClose` (and `webSocketError`): stops the socket's watch. */
137
+ readonly webSocketClose: (socket: HibernatableSocket, code?: number, reason?: string) => Promise<void>;
138
+ /**
139
+ * Reopen the watch of every accepted socket that has none in memory: after a wake from hibernation each socket receives a
140
+ * complete view again, then deltas. Messages and upgrades call it; a host also calls it when the object wakes for another
141
+ * reason (an alarm), so a resumed run reaches open pages. Idempotent.
142
+ */
143
+ readonly resume: () => Promise<void>;
144
+ /** Send a host event (`{"kind":"event","event":...}`, for example presence or the conversation list) to the sockets of an owner or conversation, or all. */
145
+ readonly broadcast: (event: unknown, to?: {
146
+ readonly owner?: string;
147
+ readonly conversationId?: string;
148
+ }) => void;
149
+ /** The attachments of the open sockets, optionally by owner or conversation: presence is these. */
150
+ readonly connections: (to?: {
151
+ readonly owner?: string;
152
+ readonly conversationId?: string;
153
+ }) => ConversationSocketAttachment[];
154
+ }
155
+ /**
156
+ * The chat transport over hibernatable WebSockets, for a Durable Object (`ctx.acceptWebSocket`). One socket carries the watch (the
157
+ * same `view` then `ops` frames as the NDJSON stream) and every call (`{"kind":"call","id","op","params","body"}`, answered by
158
+ * `{"kind":"reply","id","status","body"}`), which runs through `createChatTransportHandler` unchanged: the same validation,
159
+ * `allow`, `project` and request-id deduplication. A submit without `whenBusy` is a follow-up when the conversation is busy.
160
+ * Nothing in memory is needed between messages: the attachment holds the socket's owner and conversation, so an idle page lets
161
+ * the object hibernate, and a wake reopens the watches with a complete view. `ping` is answered `pong` (on Cloudflare by the
162
+ * platform's auto-response, without waking the object).
163
+ */
164
+ export declare function createConversationSocketHandler(options: ConversationSocketOptions): ConversationSocketHandler;
88
165
  //# sourceMappingURL=chat-transport.d.ts.map
@@ -60,6 +60,19 @@ function reads(conversation, context, project) {
60
60
  },
61
61
  };
62
62
  }
63
+ /** Serialize one watch frame; with `project` a view is projected first (a throw means `projection_failed`). */
64
+ function encodeFrame(frame, project) {
65
+ return JSON.stringify(frame.kind === 'view' && project ? { kind: 'view', view: project(frame.view) } : frame);
66
+ }
67
+ /** Open the conversation watch for an authenticated access, refusing a revoked one. Shared by the HTTP watch and the socket. */
68
+ async function openWatch(access, signal) {
69
+ const watch = await access.conversation.watch(access.context);
70
+ if (signal?.aborted || access.revoked?.aborted) {
71
+ await watch.stop();
72
+ return null;
73
+ }
74
+ return watch;
75
+ }
63
76
  /**
64
77
  * Stream newline-delimited JSON. A trusted viewer receives one view and then the exact native operation batches,
65
78
  * replayed in the browser with `@earendil-works/chord/delta`. With `project`, raw operations never leave the host:
@@ -107,7 +120,7 @@ function stream(watch, project, signal, heartbeatMs) {
107
120
  if (frame) {
108
121
  let line;
109
122
  try {
110
- line = JSON.stringify(frame.kind === 'view' && project ? { kind: 'view', view: project(frame.view) } : frame);
123
+ line = encodeFrame(frame, project);
111
124
  }
112
125
  catch {
113
126
  finished = true;
@@ -177,11 +190,9 @@ export function createChatTransportHandler(options) {
177
190
  try {
178
191
  if (request.method === 'GET' && operation === 'watch') {
179
192
  const signal = access.revoked ? AbortSignal.any([request.signal, access.revoked]) : request.signal;
180
- const watch = await conversation.watch(context);
181
- if (signal.aborted) {
182
- await watch.stop();
193
+ const watch = await openWatch(access, signal);
194
+ if (!watch)
183
195
  return failure(403, 'not-authorized');
184
- }
185
196
  return stream(watch, access.project, signal, options.heartbeatMs ?? CHAT_TRANSPORT.heartbeatMs);
186
197
  }
187
198
  if (request.method === 'GET' && operation === 'submission') {
@@ -329,3 +340,177 @@ export function routeSubmissions(conversation, admit) {
329
340
  return typeof value === 'function' ? value.bind(target) : value;
330
341
  } });
331
342
  }
343
+ /** The subprotocol a browser client offers and the handler selects. Other offered values (for example a credential) are the host's. */
344
+ export const CHAT_SOCKET_PROTOCOL = 'boring.chat';
345
+ const ownerTag = (owner) => `owner:${owner}`;
346
+ const conversationTag = (id) => `conversation:${id}`;
347
+ const tagFor = (to) => to?.conversationId !== undefined ? conversationTag(to.conversationId) : to?.owner !== undefined ? ownerTag(to.owner) : undefined;
348
+ /**
349
+ * The chat transport over hibernatable WebSockets, for a Durable Object (`ctx.acceptWebSocket`). One socket carries the watch (the
350
+ * same `view` then `ops` frames as the NDJSON stream) and every call (`{"kind":"call","id","op","params","body"}`, answered by
351
+ * `{"kind":"reply","id","status","body"}`), which runs through `createChatTransportHandler` unchanged: the same validation,
352
+ * `allow`, `project` and request-id deduplication. A submit without `whenBusy` is a follow-up when the conversation is busy.
353
+ * Nothing in memory is needed between messages: the attachment holds the socket's owner and conversation, so an idle page lets
354
+ * the object hibernate, and a wake reopens the watches with a complete view. `ping` is answered `pong` (on Cloudflare by the
355
+ * platform's auto-response, without waking the object).
356
+ */
357
+ export function createConversationSocketHandler(options) {
358
+ const { state } = options;
359
+ const Pair = globalThis.WebSocketRequestResponsePair;
360
+ if (Pair && state.setWebSocketAutoResponse)
361
+ state.setWebSocketAutoResponse(new Pair('ping', 'pong'));
362
+ /** Live watches by socket id, in memory only: empty after a wake, refilled by `attach`. */
363
+ const live = new Map();
364
+ const attachment = (socket) => socket.deserializeAttachment();
365
+ const send = (socket, value) => { try {
366
+ socket.send(value);
367
+ }
368
+ catch { /* the socket closed meanwhile */ } };
369
+ const end = (socket, reason, code = 1000) => {
370
+ send(socket, JSON.stringify({ kind: 'end', reason }));
371
+ try {
372
+ socket.close(code, reason);
373
+ }
374
+ catch { /* already closed */ }
375
+ };
376
+ function attach(socket) {
377
+ const found = attachment(socket);
378
+ if (!found)
379
+ return Promise.resolve(null);
380
+ let entry = live.get(found.socketId);
381
+ if (entry)
382
+ return entry;
383
+ entry = (async () => {
384
+ let access;
385
+ try {
386
+ access = await options.access(found);
387
+ }
388
+ catch {
389
+ access = null;
390
+ }
391
+ if (!access) {
392
+ live.delete(found.socketId);
393
+ end(socket, 'revoked', 1008);
394
+ return null;
395
+ }
396
+ const watch = await openWatch(access).catch(() => null);
397
+ if (!watch) {
398
+ live.delete(found.socketId);
399
+ end(socket, 'revoked', 1008);
400
+ return null;
401
+ }
402
+ const { project } = access;
403
+ const frame = (value) => {
404
+ try {
405
+ send(socket, encodeFrame(value, project));
406
+ }
407
+ catch {
408
+ void watch.stop();
409
+ end(socket, 'projection_failed', 1011);
410
+ }
411
+ };
412
+ frame({ kind: 'view', view: watch.value });
413
+ access.revoked?.addEventListener('abort', () => { void watch.stop(); }, { once: true });
414
+ watch.start(async (view, ops) => frame(project ? { kind: 'view', view } : { kind: 'ops', ops }));
415
+ void watch.closed.then(result => {
416
+ if (live.get(found.socketId) !== entry)
417
+ return;
418
+ live.delete(found.socketId);
419
+ // A host restart (`session_closed`) asks the client to reconnect; the other reasons are final.
420
+ end(socket, access.revoked?.aborted ? 'revoked' : result.reason, result.reason === 'session_closed' ? 1012 : 1000);
421
+ });
422
+ return { access, watch };
423
+ })();
424
+ live.set(found.socketId, entry);
425
+ return entry;
426
+ }
427
+ async function resume() { await Promise.all(state.getWebSockets().map(socket => attach(socket))); }
428
+ async function accept(socket, request) {
429
+ const found = await options.authenticate(request);
430
+ if (!found)
431
+ return false;
432
+ state.acceptWebSocket(socket, [ownerTag(found.owner), conversationTag(found.conversationId)]);
433
+ socket.serializeAttachment({ ...found, socketId: crypto.randomUUID() });
434
+ void resume();
435
+ return true;
436
+ }
437
+ async function call(socket, frame) {
438
+ const entry = await attach(socket);
439
+ if (!entry)
440
+ return;
441
+ const url = new URL('https://socket.invalid/');
442
+ for (const [key, value] of Object.entries(frame.params ?? {}))
443
+ if (typeof value === 'string')
444
+ url.searchParams.set(key, value);
445
+ url.searchParams.set('op', frame.op);
446
+ let body = frame.body;
447
+ if (frame.op === 'submit' && body !== null && typeof body === 'object' && !Array.isArray(body) && body['whenBusy'] === undefined)
448
+ body = { ...body, whenBusy: 'followUp' };
449
+ let response;
450
+ if (frame.op === 'watch')
451
+ response = failure(400, 'invalid-request');
452
+ else {
453
+ // The HTTP handler, unchanged, with this socket's access: the same checks, `allow`, `project` and request-id deduplication.
454
+ const handler = createChatTransportHandler({ authenticate: async () => entry.access });
455
+ response = await handler(new Request(url, body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }));
456
+ }
457
+ const reply = await response.json().catch(() => null);
458
+ send(socket, JSON.stringify({ kind: 'reply', id: frame.id, status: response.status, body: reply }));
459
+ }
460
+ return {
461
+ accept,
462
+ resume,
463
+ async fetch(request) {
464
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket')
465
+ return failure(426, 'upgrade-required');
466
+ const Socket = globalThis.WebSocketPair;
467
+ if (!Socket)
468
+ return failure(501, 'not-supported');
469
+ const pair = new Socket();
470
+ let accepted;
471
+ try {
472
+ accepted = await accept(pair[1], request);
473
+ }
474
+ catch {
475
+ return failure(503, 'authentication-unavailable');
476
+ }
477
+ if (!accepted)
478
+ return failure(401, 'authentication-required');
479
+ const offered = (request.headers.get('sec-websocket-protocol') ?? '').split(',').map(value => value.trim());
480
+ return new Response(null, { status: 101, webSocket: pair[0], ...(offered.includes(CHAT_SOCKET_PROTOCOL) ? { headers: { 'sec-websocket-protocol': CHAT_SOCKET_PROTOCOL } } : {}) });
481
+ },
482
+ async webSocketMessage(socket, message) {
483
+ if (message === 'ping')
484
+ return send(socket, 'pong');
485
+ let frame;
486
+ try {
487
+ frame = JSON.parse(typeof message === 'string' ? message : new TextDecoder().decode(message));
488
+ }
489
+ catch {
490
+ return;
491
+ }
492
+ if (frame?.kind !== 'call' || typeof frame.id !== 'string' || typeof frame.op !== 'string')
493
+ return;
494
+ void resume();
495
+ await call(socket, frame);
496
+ },
497
+ async webSocketClose(socket, code, reason) {
498
+ const found = attachment(socket);
499
+ const entry = found ? live.get(found.socketId) : undefined;
500
+ if (found)
501
+ live.delete(found.socketId);
502
+ const opened = await entry?.catch(() => null);
503
+ await opened?.watch?.stop();
504
+ try {
505
+ socket.close(code === undefined || code === 1005 || code === 1006 || code === 1015 ? 1000 : code, reason);
506
+ }
507
+ catch { /* already closed */ }
508
+ },
509
+ broadcast(event, to) {
510
+ const text = JSON.stringify({ kind: 'event', event });
511
+ for (const socket of state.getWebSockets(tagFor(to)))
512
+ send(socket, text);
513
+ },
514
+ connections: to => state.getWebSockets(tagFor(to)).flatMap(socket => { const found = attachment(socket); return found ? [found] : []; }),
515
+ };
516
+ }
@@ -0,0 +1,24 @@
1
+ import type { AgentChange, ToolRegistration } from '@earendil-works/pi-durable';
2
+ export declare const DELEGATE_TOOL = "delegate";
3
+ export interface DelegateOptions {
4
+ /**
5
+ * The child agent, usually a `defineAgent` result (its `id` and native `agent` change). The child conversation selects exactly its
6
+ * extensions, model and instructions: its tools are the whole toolset. Its extensions must be installed in the registry.
7
+ */
8
+ readonly agent: {
9
+ readonly id: string;
10
+ readonly agent: AgentChange;
11
+ };
12
+ /** Tool name; default `delegate`. Give each delegate of one agent its own name. */
13
+ readonly name?: string;
14
+ readonly description?: string;
15
+ }
16
+ /**
17
+ * Pi's subagent pattern (pi-durable README "Abort and Subagents") as a tool factory: the child is a conversation owned by the
18
+ * calling tool task (`ownership: { kind: 'task', taskId }`), so aborting the call aborts the child and the parent is idle only
19
+ * 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
+ * ownership index and its submission through the request id, so it never starts a second child or a second turn. Returns only the
21
+ * child's final text; `details.conversationId` names the child for a UI that shows it.
22
+ */
23
+ export declare function createDelegateTool(options: DelegateOptions): ToolRegistration;
24
+ //# sourceMappingURL=delegate.d.ts.map
@@ -0,0 +1,44 @@
1
+ import { AssistantEntry, configure, defineTool } from '@earendil-works/pi-durable';
2
+ import { Type } from '@earendil-works/pi-ai';
3
+ export const DELEGATE_TOOL = 'delegate';
4
+ /**
5
+ * Pi's subagent pattern (pi-durable README "Abort and Subagents") as a tool factory: the child is a conversation owned by the
6
+ * calling tool task (`ownership: { kind: 'task', taskId }`), so aborting the call aborts the child and the parent is idle only
7
+ * once the child is. Replay-safe: a rerun of the same tool task (after a crash or eviction) finds its child through the native
8
+ * ownership index and its submission through the request id, so it never starts a second child or a second turn. Returns only the
9
+ * child's final text; `details.conversationId` names the child for a UI that shows it.
10
+ */
11
+ export function createDelegateTool(options) {
12
+ const name = options.name ?? DELEGATE_TOOL;
13
+ // A task-owned conversation starts as a copy of its owner's agent; reset the inherited filters so the child's own extensions are
14
+ // its whole toolset, then apply the child agent. `cwd` stays inherited unless the child agent sets one.
15
+ const child = { tools: null, modelTools: null, instructions: null, thinkingLevel: null, ...options.agent.agent };
16
+ return defineTool({
17
+ name,
18
+ description: options.description ?? `Delegate a self-contained task to the ${options.agent.id} agent and get its final answer back.`,
19
+ parameters: Type.Object({ task: Type.String({ minLength: 1, description: 'The complete task; the agent sees nothing else of this conversation.' }) }, { additionalProperties: false }),
20
+ replay: 'safe',
21
+ execute: async (args, api, context) => {
22
+ const childId = await api.commit(async (tx) => {
23
+ const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
24
+ if (existing !== undefined)
25
+ return existing.id;
26
+ const created = await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } });
27
+ await configure(tx, created.id, child);
28
+ return created.id;
29
+ }, context);
30
+ await api.details({ conversationId: childId }, context);
31
+ const conversation = await api.conversation(childId, context);
32
+ if (conversation === undefined)
33
+ throw new Error(`Delegate conversation ${childId} is missing`);
34
+ const submission = await conversation.submit({ type: 'input', content: args.task, requestId: `${name}:${api.taskId}` }, context);
35
+ const settled = await submission.wait(context);
36
+ if (settled.status !== 'done' || settled.type !== 'input')
37
+ throw new Error(`The ${options.agent.id} agent did not answer: ${settled.status}`);
38
+ const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), context);
39
+ const message = answer?.model?.find(part => part.role === 'assistant');
40
+ const text = message?.role === 'assistant' ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\n') : '';
41
+ return { output: [{ type: 'text', text }], details: { conversationId: childId } };
42
+ },
43
+ });
44
+ }
@@ -0,0 +1,77 @@
1
+ import type { ConversationId, DocumentReader, Extension, TaskId, TaskInspection, Tx } from '@earendil-works/pi-durable';
2
+ import type { Context } from '@earendil-works/chord';
3
+ export declare const SCHEDULE_TOOL = "schedule";
4
+ export declare const LIST_SCHEDULES_TOOL = "list_schedules";
5
+ export declare const CANCEL_SCHEDULE_TOOL = "cancel_schedule";
6
+ /** Entry kind of a delivered instruction: typed provenance, so a viewer can tell it from a person's message. */
7
+ export declare const SCHEDULED_INPUT_KIND = "boring.schedule.delivery";
8
+ export type ScheduleRecord = {
9
+ /** Stable id the model lists and cancels by. */
10
+ readonly id: string;
11
+ /** Idempotency key: scheduling again with it returns this schedule instead of creating another. */
12
+ readonly operationId: string;
13
+ readonly instruction: string;
14
+ /** Unix milliseconds. */
15
+ readonly runAt: number;
16
+ readonly taskId: TaskId;
17
+ readonly cancelled?: boolean;
18
+ };
19
+ type DeliveryInput = {
20
+ id: string;
21
+ conversationId: ConversationId;
22
+ instruction: string;
23
+ runAt: number;
24
+ };
25
+ type DeliveryState = {
26
+ phase: 'wait';
27
+ } | {
28
+ phase: 'deliver';
29
+ };
30
+ /** Pending schedules of one conversation. Forks start empty: a schedule belongs to the conversation that made it. */
31
+ export declare const ConversationSchedules: import("@earendil-works/pi-durable").ConversationDocToken<{
32
+ items: ScheduleRecord[];
33
+ next: number;
34
+ }>;
35
+ /**
36
+ * The native durable task behind one schedule: it sleeps on the Harness clock (`runtime.sleep`), then delivers the instruction to
37
+ * its conversation as a follow-up input whose request id is the schedule id, so a replayed delivery never submits twice. Owned by
38
+ * the conversation as background work: a person stopping the current turn does not cancel it. No second scheduler: Pi's own
39
+ * scheduler runs it, and a restart resumes it.
40
+ */
41
+ export declare const ScheduledDelivery: import("@earendil-works/pi-durable").Task<DeliveryInput, DeliveryState, {
42
+ delivered: boolean;
43
+ }, object>;
44
+ /**
45
+ * Create a schedule in a commit, or return the one `operationId` already made in this conversation. For hosts that schedule
46
+ * outside a tool (an API route); the `schedule` tool calls it too.
47
+ */
48
+ export declare function addSchedule(tx: Tx, conversationId: ConversationId, request: {
49
+ readonly operationId: string;
50
+ readonly instruction: string;
51
+ readonly runAt: number;
52
+ }): Promise<ScheduleRecord>;
53
+ /** Mark a pending schedule of this conversation cancelled; its task wakes and ends without delivering. False when there is none. */
54
+ export declare function cancelSchedule(tx: Tx, conversationId: ConversationId, id: string): Promise<boolean>;
55
+ /** Pending, uncancelled schedules of one conversation, soonest first. */
56
+ export declare function listSchedules(reader: DocumentReader, conversationId: ConversationId, context: Context): Promise<ScheduleRecord[]>;
57
+ /**
58
+ * The deadline of a schedule task that is still sleeping, from `harness.inspect()`; `undefined` for every other task. A host with
59
+ * one wake-up timer (a Durable Object alarm) sets it to the earliest deadline, and wakes Pi then; the deadlines stay Pi's tasks.
60
+ */
61
+ export declare function scheduledDeadline(task: TaskInspection): number | undefined;
62
+ export interface ScheduleExtensionOptions {
63
+ /** The clock deadlines are computed on; give the Harness's own `now`. Default `Date.now`. */
64
+ readonly now?: () => number;
65
+ /** Called with each new (or returned) deadline, so a host with one wake-up timer can move it earlier. */
66
+ readonly onDeadline?: (runAt: number, context: Context) => void | Promise<void>;
67
+ /** Longest delay accepted; default 366 days. */
68
+ readonly maxDelaySeconds?: number;
69
+ }
70
+ /**
71
+ * `schedule`, `list_schedules` and `cancel_schedule` plus the delivery task, as one native extension. A conversation lists and
72
+ * cancels only its own schedules. `schedule` is idempotent on `operationId` (default: the tool task, so a replayed call returns
73
+ * the schedule it made).
74
+ */
75
+ export declare function createScheduleExtension(options?: ScheduleExtensionOptions): Extension;
76
+ export {};
77
+ //# sourceMappingURL=schedule.d.ts.map
@@ -0,0 +1,191 @@
1
+ import { defineDoc, defineExtension, defineTask, defineTool } from '@earendil-works/pi-durable';
2
+ import { Type } from '@earendil-works/pi-ai';
3
+ import { withAbortSignal } from '@earendil-works/chord/context';
4
+ export const SCHEDULE_TOOL = 'schedule';
5
+ export const LIST_SCHEDULES_TOOL = 'list_schedules';
6
+ export const CANCEL_SCHEDULE_TOOL = 'cancel_schedule';
7
+ /** Entry kind of a delivered instruction: typed provenance, so a viewer can tell it from a person's message. */
8
+ export const SCHEDULED_INPUT_KIND = 'boring.schedule.delivery';
9
+ const MAX_DELAY_SECONDS = 366 * 24 * 60 * 60;
10
+ const MAX_INSTRUCTION = 4000;
11
+ const TIMEZONE_SUFFIX = /(?:Z|[+-]\d{2}:\d{2})$/i;
12
+ /** Pending schedules of one conversation. Forks start empty: a schedule belongs to the conversation that made it. */
13
+ export const ConversationSchedules = defineDoc({
14
+ kind: 'boring.schedules', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({ items: [], next: 1 }),
15
+ });
16
+ const remove = (items, id) => { const index = items.findIndex(item => item.id === id); if (index !== -1)
17
+ items.splice(index, 1); };
18
+ /** Sleep on the Harness clock until `runAt`; true when the schedule was cancelled first. */
19
+ async function sleepUnlessCancelled(runtime, input, context) {
20
+ const cancelledIn = (value) => value?.items.find(item => item.id === input.id)?.cancelled === true;
21
+ const watch = await runtime.watchDoc(ConversationSchedules, input.conversationId, context);
22
+ if (watch === undefined)
23
+ return false;
24
+ const cancel = new AbortController();
25
+ try {
26
+ if (cancelledIn(watch.value))
27
+ return true;
28
+ watch.start(async (value) => { if (cancelledIn(value))
29
+ cancel.abort(); });
30
+ await runtime.sleep(input.runAt, withAbortSignal(cancel.signal, context));
31
+ return false;
32
+ }
33
+ catch (error) {
34
+ if (cancel.signal.aborted)
35
+ return true;
36
+ throw error;
37
+ }
38
+ finally {
39
+ await watch.stop();
40
+ }
41
+ }
42
+ /**
43
+ * The native durable task behind one schedule: it sleeps on the Harness clock (`runtime.sleep`), then delivers the instruction to
44
+ * its conversation as a follow-up input whose request id is the schedule id, so a replayed delivery never submits twice. Owned by
45
+ * the conversation as background work: a person stopping the current turn does not cancel it. No second scheduler: Pi's own
46
+ * scheduler runs it, and a restart resumes it.
47
+ */
48
+ export const ScheduledDelivery = defineTask({
49
+ name: 'boring.schedule.deliver',
50
+ version: 1,
51
+ initial: () => ({ phase: 'wait' }),
52
+ phases: {
53
+ wait: async (task, runtime, context) => {
54
+ if (await sleepUnlessCancelled(runtime, task.input, context)) {
55
+ await runtime.commit(async (tx) => {
56
+ remove((await tx.doc(ConversationSchedules, task.input.conversationId)).items, task.input.id);
57
+ return { status: 'terminal', outcome: { status: 'completed', result: { delivered: false } } };
58
+ }, context);
59
+ return;
60
+ }
61
+ await runtime.commit(() => ({ status: 'running', checkpoint: { phase: 'deliver' } }), context);
62
+ },
63
+ deliver: async (task, runtime, context) => {
64
+ const conversation = await runtime.conversation(task.input.conversationId, context);
65
+ if (conversation === undefined)
66
+ runtime.report(new Error(`Schedule ${task.input.id}: conversation ${task.input.conversationId} is missing`));
67
+ else {
68
+ await conversation.submit({
69
+ type: 'input', requestId: task.input.id, whenBusy: 'followUp', content: `Scheduled instruction ${task.input.id} is due now: ${task.input.instruction}`,
70
+ entry: { kind: SCHEDULED_INPUT_KIND, data: { scheduleId: task.input.id, instruction: task.input.instruction, runAt: task.input.runAt } },
71
+ }, context);
72
+ }
73
+ await runtime.commit(async (tx) => {
74
+ remove((await tx.doc(ConversationSchedules, task.input.conversationId)).items, task.input.id);
75
+ return { status: 'terminal', outcome: { status: 'completed', result: { delivered: conversation !== undefined } } };
76
+ }, context);
77
+ },
78
+ },
79
+ abort: async (task, runtime, context) => {
80
+ await runtime.commit(async (tx) => {
81
+ remove((await tx.doc(ConversationSchedules, task.input.conversationId)).items, task.input.id);
82
+ return { status: 'terminal', outcome: { status: 'aborted' } };
83
+ }, context);
84
+ },
85
+ });
86
+ /**
87
+ * Create a schedule in a commit, or return the one `operationId` already made in this conversation. For hosts that schedule
88
+ * outside a tool (an API route); the `schedule` tool calls it too.
89
+ */
90
+ export async function addSchedule(tx, conversationId, request) {
91
+ const schedules = await tx.doc(ConversationSchedules, conversationId);
92
+ const existing = schedules.items.find(item => item.operationId === request.operationId);
93
+ if (existing !== undefined)
94
+ return { ...existing };
95
+ const id = `schedule-${schedules.next++}`;
96
+ const taskId = await tx.createTask(ScheduledDelivery, { id, conversationId, instruction: request.instruction, runAt: request.runAt }, { ownership: { kind: 'conversation' }, conversationId, background: true });
97
+ const record = { id, operationId: request.operationId, instruction: request.instruction, runAt: request.runAt, taskId };
98
+ schedules.items.push(record);
99
+ return { ...record };
100
+ }
101
+ /** Mark a pending schedule of this conversation cancelled; its task wakes and ends without delivering. False when there is none. */
102
+ export async function cancelSchedule(tx, conversationId, id) {
103
+ const item = (await tx.doc(ConversationSchedules, conversationId)).items.find(candidate => candidate.id === id);
104
+ if (item === undefined || item.cancelled)
105
+ return false;
106
+ item.cancelled = true;
107
+ return true;
108
+ }
109
+ /** Pending, uncancelled schedules of one conversation, soonest first. */
110
+ export async function listSchedules(reader, conversationId, context) {
111
+ const value = await reader.snapshot(ConversationSchedules, conversationId, context);
112
+ return (value?.items ?? []).filter(item => !item.cancelled).map(item => ({ ...item })).sort((a, b) => a.runAt - b.runAt);
113
+ }
114
+ /**
115
+ * The deadline of a schedule task that is still sleeping, from `harness.inspect()`; `undefined` for every other task. A host with
116
+ * one wake-up timer (a Durable Object alarm) sets it to the earliest deadline, and wakes Pi then; the deadlines stay Pi's tasks.
117
+ */
118
+ export function scheduledDeadline(task) {
119
+ const { record } = task;
120
+ if (record.kind !== ScheduledDelivery.definition.name || record.abortRequested)
121
+ return undefined;
122
+ if (record.state.status === 'completing' || record.state.status === 'terminal')
123
+ return undefined;
124
+ const checkpoint = record.state.checkpoint;
125
+ const runAt = record.input?.runAt;
126
+ return checkpoint?.phase === 'wait' && typeof runAt === 'number' ? runAt : undefined;
127
+ }
128
+ /**
129
+ * `schedule`, `list_schedules` and `cancel_schedule` plus the delivery task, as one native extension. A conversation lists and
130
+ * cancels only its own schedules. `schedule` is idempotent on `operationId` (default: the tool task, so a replayed call returns
131
+ * the schedule it made).
132
+ */
133
+ export function createScheduleExtension(options = {}) {
134
+ const now = options.now ?? Date.now;
135
+ const maxDelay = options.maxDelaySeconds ?? MAX_DELAY_SECONDS;
136
+ const describe = (record) => ({ id: record.id, instruction: record.instruction, runAt: new Date(record.runAt).toISOString() });
137
+ const schedule = defineTool({
138
+ name: SCHEDULE_TOOL,
139
+ description: 'Schedule an instruction for this conversation at a later time. When it is due, the conversation receives the instruction and carries it out. Give exactly one of delaySeconds or at.',
140
+ parameters: Type.Object({
141
+ instruction: Type.String({ minLength: 1, maxLength: MAX_INSTRUCTION, description: 'What to do when it is due, as a self-contained request without timing words.' }),
142
+ delaySeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: maxDelay })),
143
+ at: Type.Optional(Type.String({ description: 'ISO 8601 date-time with a timezone offset, for example 2026-11-01T09:00:00+01:00.' })),
144
+ operationId: Type.Optional(Type.String({ minLength: 1, maxLength: 200, description: 'Idempotency key: the same key returns the existing schedule.' })),
145
+ }, { additionalProperties: false }),
146
+ replay: 'safe',
147
+ execute: async (args, api, context) => {
148
+ const time = now();
149
+ if ((args.delaySeconds === undefined) === (args.at === undefined))
150
+ return refused('give exactly one of delaySeconds or at');
151
+ let runAt;
152
+ if (args.delaySeconds !== undefined)
153
+ runAt = time + args.delaySeconds * 1000;
154
+ else {
155
+ runAt = TIMEZONE_SUFFIX.test(args.at) ? Date.parse(args.at) : Number.NaN;
156
+ if (!Number.isFinite(runAt))
157
+ return refused('at must be an ISO 8601 date-time with a timezone offset');
158
+ if (runAt <= time)
159
+ return refused('at must be in the future');
160
+ if (runAt > time + maxDelay * 1000)
161
+ return refused('at is too far in the future');
162
+ }
163
+ const operationId = args.operationId ?? `tool:${api.taskId}`;
164
+ const record = await api.commit(tx => addSchedule(tx, api.conversationId, { operationId, instruction: args.instruction, runAt }), context);
165
+ await options.onDeadline?.(record.runAt, context);
166
+ return { output: [{ type: 'text', text: `Scheduled ${record.id} for ${describe(record).runAt}: ${JSON.stringify(record.instruction)}. Nothing more is needed now.` }], details: describe(record) };
167
+ },
168
+ });
169
+ const list = defineTool({
170
+ name: LIST_SCHEDULES_TOOL,
171
+ description: 'List the pending schedules of this conversation.',
172
+ parameters: Type.Object({}, { additionalProperties: false }),
173
+ replay: 'safe',
174
+ execute: async (_args, api, context) => {
175
+ const items = (await listSchedules(api, api.conversationId, context)).map(describe);
176
+ return { output: [{ type: 'text', text: JSON.stringify(items) }], details: { items } };
177
+ },
178
+ });
179
+ const cancel = defineTool({
180
+ name: CANCEL_SCHEDULE_TOOL,
181
+ description: 'Cancel a pending schedule of this conversation by its id.',
182
+ parameters: Type.Object({ id: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
183
+ replay: 'safe',
184
+ execute: async (args, api, context) => {
185
+ const cancelled = await api.commit(tx => cancelSchedule(tx, api.conversationId, args.id), context);
186
+ return cancelled ? { output: [{ type: 'text', text: `Cancelled ${args.id}.` }] } : { isError: true, output: [{ type: 'text', text: `No pending schedule ${args.id} in this conversation.` }] };
187
+ },
188
+ });
189
+ return defineExtension({ name: 'boring.schedule', tools: [schedule, list, cancel], tasks: [ScheduledDelivery] });
190
+ }
191
+ const refused = (reason) => ({ isError: true, output: [{ type: 'text', text: `Not scheduled: ${reason}.` }] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -90,6 +90,10 @@
90
90
  "types": "./dist/agents.d.ts",
91
91
  "import": "./dist/agents.js"
92
92
  },
93
+ "./schedule": {
94
+ "types": "./dist/schedule.d.ts",
95
+ "import": "./dist/schedule.js"
96
+ },
93
97
  "./chat-transport": {
94
98
  "types": "./dist/chat-transport.d.ts",
95
99
  "import": "./dist/chat-transport.js"
@@ -139,8 +143,8 @@
139
143
  "@earendil-works/pi-durable": "~1.2.0",
140
144
  "@earendil-works/pi-ai": "~1.2.0",
141
145
  "@earendil-works/chord": "~1.2.0",
142
- "@hachej/boring-files": "^0.4.0",
143
- "@hachej/boring-ui-kit": "^0.4.0"
146
+ "@hachej/boring-files": "^0.4.1",
147
+ "@hachej/boring-ui-kit": "^0.4.1"
144
148
  },
145
149
  "peerDependenciesMeta": {
146
150
  "@hachej/boring-files": {