@hachej/boring-agent 0.3.2 → 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 +39 -7
- package/dist/agents.d.ts +10 -0
- package/dist/agents.js +16 -9
- package/dist/ask-user.js +12 -5
- package/dist/chat-transport.d.ts +77 -0
- package/dist/chat-transport.js +205 -8
- package/dist/delegate.d.ts +24 -0
- package/dist/delegate.js +44 -0
- package/dist/prompt-context.d.ts +9 -0
- package/dist/prompt-context.js +26 -0
- package/dist/schedule.d.ts +77 -0
- package/dist/schedule.js +191 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -134,7 +134,29 @@ Choose the seam by how the text changes and who should see it:
|
|
|
134
134
|
|
|
135
135
|
Failure is fail-open and Pi's: a throwing `context` is reported through `HarnessOptions.onReport` and the request goes out without the context (Pi's `hooks.each`, scheduler.js). An aborted turn rethrows. This is advisory text, not policy or authorization; do not use it to enforce a decision. The text is sent to the model provider and gateways or telemetry that log request bodies may see it; the model can quote it into a stored assistant message. Compaction summarizes the stored transcript only, so the context is not folded into summaries. After a crash the hook runs again at recovery, so the value is fresh per attempt, not replayed. Placement: before the round's question when that is the last message; after tool results or an assistant continuation, at the tail. Tests: `test/packages/agent-prompt-context.test.mjs`.
|
|
136
136
|
|
|
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. Tests: `test/packages/agent-chat-transport-context.test.mjs`, `test/packages/ui-remote-chat-context.test.mjs`.
|
|
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
|
+
|
|
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).
|
|
138
160
|
|
|
139
161
|
## Native questions
|
|
140
162
|
|
|
@@ -160,9 +182,9 @@ The native delivery task is version 2. Existing version 1 records migrate with e
|
|
|
160
182
|
|
|
161
183
|
`resolveAccess` may return a Promise. When recovery reads the original actor from native producer input, pass that already-authenticated access as the sixth admission argument: `admit(tx, createProducer, target, taskOptions, context, access)`. Admission captures its identity before awaiting producer creation. Publication still resolves current access and compares the original identity. Omitting the sixth argument preserves resolution during admission; such a resolver must not read the same Harness or call `tx.task` after producer writes. Native transactions reject table reads after their first write. Explicit access is trusted host input, not authentication or a grant. Resolved credential fields are copied before digest, memo and provider awaits.
|
|
162
184
|
|
|
163
|
-
Run `node
|
|
185
|
+
Run `node fixtures/background-document.mjs` after building for fictional question, native producer and document delivery without a model, browser or shell. The example resolves a fixed fictional answer in trusted host code; it is not an authenticated remote human journey.
|
|
164
186
|
|
|
165
|
-
Import interfaces with `import type`. See [scaffold guide](../../docs/contracts/SCAFFOLD.md), [native examples](../../
|
|
187
|
+
Import interfaces with `import type`. See [scaffold guide](../../docs/contracts/SCAFFOLD.md), [native examples](../../fixtures/native-compositions.ts) and [implementation evidence](../../docs/implementation/PARTIAL.md).
|
|
166
188
|
|
|
167
189
|
## Authorized conversation text
|
|
168
190
|
|
|
@@ -182,7 +204,7 @@ The adapter keeps the last bounded delivered window and no update queue. A slow
|
|
|
182
204
|
|
|
183
205
|
TLS, cookies, CORS, socket/proxy lifecycle, browser presentation and deployment authentication remain host qualifications. Public projection tests exercise actual native watches through in-process Fetch/Web Streams. `npm run test:projection-consumer` installs the agent archive without files/UI/execution packages, checks strict declarations, repeats the tests and builds the receiver for a browser. It requires the pinned registry archives in a writable npm cache.
|
|
184
206
|
|
|
185
|
-
Run `node
|
|
207
|
+
Run `node fixtures/conversation-projection.mjs` after building for a fictional authenticated stream and idle revocation demonstration.
|
|
186
208
|
|
|
187
209
|
## Authenticated question responses
|
|
188
210
|
|
|
@@ -194,7 +216,7 @@ Responses expose the narrow native decision, with `no-store` headers. An observe
|
|
|
194
216
|
|
|
195
217
|
`test/packages/agent-question-response.test.mjs` exercises real native transactions through Fetch requests, including duplicate answers, identity/policy refusal, streamed byte limits, unacknowledged source cancellation and lost response recovery. `npm run test:question-consumer` repeats these scenarios from the installed agent and files tarballs without UI/execution packages; the handler reads bodies through `@hachej/boring-files/request-guard`, so install `@hachej/boring-files` (an optional peer) with this subpath. Set `npm_config_cache` to a writable cache of the pinned dependencies. Socket/browser authentication, remote inbox presentation and human identity-provider integration remain unqualified.
|
|
196
218
|
|
|
197
|
-
The strict TypeScript consumer recipe additionally installs `@modelcontextprotocol/sdk@1.32.
|
|
219
|
+
The strict TypeScript consumer recipe additionally installs `@modelcontextprotocol/sdk@1.32.1`. Pinned Pi model declarations transitively load `@google/genai`, whose declarations reference that optional SDK. This dependency is needed for this `skipLibCheck: false` recipe even though the question runtime does not call MCP. The consumer does not establish strict checking with only the agent's declared peers installed.
|
|
198
220
|
|
|
199
221
|
`@hachej/boring-agent/git` exports `createGitTool(repository?)`. Register this ordinary native tool with the same host-created `@hachej/boring-files/git` repository used by Bash or direct callers, or without one: each call then uses the `repository` of its own workspace binding (the one attached to the call's env, see [Workspace per call](#workspace-per-call)), so one tool serves a repository per person. It exposes the local service operations and preserves native ToolTask execution. The tool is always unsafe to replay because working Git has no publication receipts. The repository's host-bound authorization applies on every call. No environment or shell is mandatory for a file-only repository binding.
|
|
200
222
|
|
|
@@ -222,7 +244,7 @@ Native configuration retains tool names. A later registry replacement or native
|
|
|
222
244
|
|
|
223
245
|
The resource must be UTF-8 `application/json`, optionally with `charset=utf-8`. The default accepted size is 65,536 bytes; `maxBytes` accepts integers from 1 to 1,048,576. This limits accepted bytes after the reader returns, not provider allocation. The loader preserves borrowed tool/provider ownership and checks an optional abort signal after awaited operations; it cannot interrupt a noncooperative reader or resolver.
|
|
224
246
|
|
|
225
|
-
Run `node
|
|
247
|
+
Run `node fixtures/agent-definitions.mjs` after building for a fictional definition, explicit native configuration and real document publication. `npm run test:definitions-consumer` installs files/agent archives with pinned registry dependencies, checks strict declarations and repeats exact-read, native publication, reopen and registry-replacement tests. Like the question consumer, this strict fixture explicitly installs the pinned optional model SDK declaration dependencies.
|
|
226
248
|
|
|
227
249
|
## Structured output and native tool evidence
|
|
228
250
|
|
|
@@ -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
|
|
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 (
|
|
18
|
-
own.push(defineExtension({ name: `agent.${spec.id}`, tools
|
|
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 = [...
|
|
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) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
19
|
-
return 'give options, or
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/dist/chat-transport.d.ts
CHANGED
|
@@ -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
|
package/dist/chat-transport.js
CHANGED
|
@@ -33,8 +33,8 @@ async function body(request) {
|
|
|
33
33
|
*/
|
|
34
34
|
function reads(conversation, context, project) {
|
|
35
35
|
return {
|
|
36
|
-
async entries(limit, cursor) {
|
|
37
|
-
const page = await conversation.entries(
|
|
36
|
+
async entries(query, limit, cursor) {
|
|
37
|
+
const page = await conversation.entries(query, limit, cursor, context);
|
|
38
38
|
if (!project)
|
|
39
39
|
return page;
|
|
40
40
|
const record = await conversation.commit(tx => tx.conversation(conversation.id), context);
|
|
@@ -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 =
|
|
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
|
|
181
|
-
if (
|
|
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') {
|
|
@@ -207,7 +218,19 @@ export function createChatTransportHandler(options) {
|
|
|
207
218
|
return failure(400, 'invalid-request');
|
|
208
219
|
}
|
|
209
220
|
}
|
|
210
|
-
const
|
|
221
|
+
const entryId = (encoded) => {
|
|
222
|
+
if (encoded === null)
|
|
223
|
+
return undefined;
|
|
224
|
+
const value = Number(encoded);
|
|
225
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
226
|
+
throw new RequestGuardError(400, 'Invalid native entry bound');
|
|
227
|
+
return value;
|
|
228
|
+
};
|
|
229
|
+
const maxEntryId = entryId(params.get('maxEntryId')), minEntryId = entryId(params.get('minEntryId'));
|
|
230
|
+
const order = params.get('order');
|
|
231
|
+
if (order !== null && order !== 'ascending' && order !== 'descending')
|
|
232
|
+
return failure(400, 'invalid-request');
|
|
233
|
+
const page = await read.entries({ ...(maxEntryId === undefined ? {} : { maxEntryId }), ...(minEntryId === undefined ? {} : { minEntryId }), ...(order === null ? {} : { order }) }, limit, cursor);
|
|
211
234
|
if (access.revoked?.aborted)
|
|
212
235
|
return failure(403, 'not-authorized');
|
|
213
236
|
return json({ page });
|
|
@@ -317,3 +340,177 @@ export function routeSubmissions(conversation, admit) {
|
|
|
317
340
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
318
341
|
} });
|
|
319
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
|
package/dist/delegate.js
ADDED
|
@@ -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
|
+
}
|
package/dist/prompt-context.d.ts
CHANGED
|
@@ -22,4 +22,13 @@ export interface PromptContextOptions {
|
|
|
22
22
|
*/
|
|
23
23
|
export declare function withRequestContext(messages: readonly Message[], text: string, tag?: string): Message[];
|
|
24
24
|
export declare function createPromptContext(options: PromptContextOptions): Extension;
|
|
25
|
+
/**
|
|
26
|
+
* The text for the browser's request context: the screen's `{ name, params }` with `placement` beside them, as `useRemoteChat` sends it
|
|
27
|
+
* (an older client sends only `{ name, params }`, which still renders; a `{ screen }` wrapper is read too). The host calls this in
|
|
28
|
+
* `createPromptContext({ context })` on the value it stored by conversation id. `placement` is `docked` or `floating` and picks the
|
|
29
|
+
* agent's role. Both are advisory text from the browser: placement changes behaviour and never authority (a docked agent writes no
|
|
30
|
+
* more than a floating one), and anything else in the value is ignored.
|
|
31
|
+
* See docs/architecture/PAGE-TOOLS.md (PLACE-1..3).
|
|
32
|
+
*/
|
|
33
|
+
export declare function renderRequestContext(value: unknown): string | undefined;
|
|
25
34
|
//# sourceMappingURL=prompt-context.d.ts.map
|
package/dist/prompt-context.js
CHANGED
|
@@ -36,3 +36,29 @@ export function createPromptContext(options) {
|
|
|
36
36
|
})],
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
|
+
const ROLES = {
|
|
40
|
+
docked: 'The chat is docked beside the page: you lead. Open the pages, records and files that answer the question in the viewer, and say what you opened.',
|
|
41
|
+
floating: 'The chat floats over the page: the person leads. Do not move the page unless asked; answer briefly about what is on screen and suggest where to go.',
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The text for the browser's request context: the screen's `{ name, params }` with `placement` beside them, as `useRemoteChat` sends it
|
|
45
|
+
* (an older client sends only `{ name, params }`, which still renders; a `{ screen }` wrapper is read too). The host calls this in
|
|
46
|
+
* `createPromptContext({ context })` on the value it stored by conversation id. `placement` is `docked` or `floating` and picks the
|
|
47
|
+
* agent's role. Both are advisory text from the browser: placement changes behaviour and never authority (a docked agent writes no
|
|
48
|
+
* more than a floating one), and anything else in the value is ignored.
|
|
49
|
+
* See docs/architecture/PAGE-TOOLS.md (PLACE-1..3).
|
|
50
|
+
*/
|
|
51
|
+
export function renderRequestContext(value) {
|
|
52
|
+
if (typeof value !== 'object' || value === null)
|
|
53
|
+
return undefined;
|
|
54
|
+
const { placement, screen } = value;
|
|
55
|
+
const lines = [];
|
|
56
|
+
if (placement === 'docked' || placement === 'floating')
|
|
57
|
+
lines.push(`Placement: ${placement}. ${ROLES[placement]}`);
|
|
58
|
+
const shown = typeof screen === 'object' && screen !== null ? screen : value;
|
|
59
|
+
if (typeof shown.name === 'string') {
|
|
60
|
+
const { name, params } = shown;
|
|
61
|
+
lines.push(`The person is looking at: ${name}${params && typeof params === 'object' && Object.keys(params).length ? ` ${JSON.stringify(params)}` : ''}`);
|
|
62
|
+
}
|
|
63
|
+
return lines.length ? lines.join('\n') : undefined;
|
|
64
|
+
}
|
|
@@ -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
|
package/dist/schedule.js
ADDED
|
@@ -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.
|
|
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.
|
|
143
|
-
"@hachej/boring-ui-kit": "^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": {
|