@hachej/boring-agent 0.2.2 → 0.2.4

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
@@ -56,7 +56,7 @@ Limits: a run is admitted against its hold, not stopped mid-run (no per-call fen
56
56
 
57
57
  ## Artifacts
58
58
 
59
- `createPresentTool({ workspace, resolveAccess?, maxBytes? })` from `@hachej/boring-agent/artifacts` returns the native `present(path)` tool (its workspace resolved per call like the guard's): the agent writes a workspace file with its ordinary file tools and presents it, and the chat shows a card that points at that file (`target`, the revision shown, no `id` or `ordinal`). The file's versions are the history of the workspace provider (`@hachej/boring-files/workspace`, whose `keep` retains the presented revision); a card of an older revision pins it and a card of the newest follows the file as it changes. The tool writes nothing and refuses files that are missing, not UTF-8 text or larger than 256 KiB. How to use it is in the tool's own description, so a host does not repeat it in its prompt: substantial output goes to a file that is then presented, short answers (and anything asked for in the chat) stay in the chat, and a revision re-reads the file, changes it at the same path and presents it again. A host adds only what is its own, such as what its HTML preview allows.
59
+ `createPresentTool({ workspace, resolveAccess?, maxBytes? })` from `@hachej/boring-agent/artifacts` returns the native `present(path)` tool (its workspace resolved per call like the guard's): the agent writes a workspace file with its ordinary file tools and presents it, and the chat shows a card that points at that file (`target`, the revision shown, no `id` or `ordinal`). The file's versions are the history of the workspace provider (`@hachej/boring-files/workspace`, whose `keep` retains the presented revision); a card of an older revision pins it and a card of the newest follows the file as it changes. The tool writes nothing and refuses files that are missing, not UTF-8 text or larger than 256 KiB. An opt-in `ack: { timeoutMs?, viewers? }` makes `present` wait until a page has mounted the presented revision: the page answers `{ kind: 'opened', instanceId, path, revision }` or `{ kind: 'refused', reason }` through the chat's `answer` (the transport's `?op=answer`), and the result carries `opened: true` with `viewer`, `opened: false` with `refused` (for example `closed by user`), `opened: 'unconfirmed'` after `timeoutMs` (default 15 s) or `opened: false, viewer: 'none'` at once when `viewers()` says no page watches the conversation. The wait is the durable wait of `ask_user` (`packages/agent/src/view-open.ts`, a conversation document keyed by the call); the deadline is stored, so a restart cannot extend it, and the first valid answer wins. Without `ack` the result is unchanged. `answerViewOpen` settles an answer; the host chains it with `answerUserQuestion` and `answerBrowserTask` in its `answer` handler. How to use it is in the tool's own description, so a host does not repeat it in its prompt: substantial output goes to a file that is then presented, short answers (and anything asked for in the chat) stay in the chat, and a revision re-reads the file, changes it at the same path and presents it again. A host adds only what is its own, such as what its HTML preview allows.
60
60
 
61
61
  `workspaceMentionReader(files, access, { maxReadBytes? })` is the reader for a host with a workspace provider: it reads the person's own published view through the provider (the access check the file viewers use) and has no containment check of its own. A host that has no provider has no reader here; it writes a `MentionReader` over whatever it does have.
62
62
 
@@ -72,6 +72,8 @@ The descriptor's `id` and `ordinal` are optional and `present` sets neither; `pa
72
72
 
73
73
  `requireApproval(tool, { summarize })` from `@hachej/boring-agent/approval` wraps a native tool so each call waits for the person's Approve or Deny before `execute`. It reuses the ask-user mechanism (`askPerson` from `@hachej/boring-agent/ask-user`): a conversation document keyed by the tool call ID, answered with `answerUserQuestion` through the chat transport's `answer` operation, and shown by the `pi-chat` approval card. The check runs inside the tool's own execution in host code, so the model cannot skip it. While the call waits, the one-line `summarize` text is also published as the call's running details under `APPROVAL_DETAILS` (it stays as the result's details unless the tool returns its own). That is also how `pi-chat` recognizes a gated call: no list of tool names is needed. The card shows the summary as the headline with the raw arguments collapsed. Deny (or a stopped conversation) returns an error result starting `DENIED_PREFIX` and `execute` never runs. The wrapper is replay-safe: a pending approval survives a restart, and a retained native memo makes execution at most once (after a crash during execution the model gets an "outcome unknown" error instead of a repeat of the change). Reads are simply not wrapped.
74
74
 
75
+ `createSandboxExtension({ tools, flags?, simulate? })` from `@hachej/boring-agent/sandbox` is a fail-closed sandbox as a native extension (`boring.sandbox`, or `name`). Its wraps replace each listed tool that the host flags `mutates` or `writes`, or does not declare `sandboxSafe` or `mutates: false`/`writes: false` (in `flags`), with a recorder: the call is never executed, the model gets `simulate(name, args)` (default `defaultAck`), and the result's details carry `{ simulated: true, tool, arguments }` under `SANDBOX_DETAILS`, which `simulatedCalls(results)` reads back. Its `beforeTool` hook blocks any call whose name is not in `tools`, so a tool added mid-run cannot run unnamed. Select it per conversation by including it in the complete selected list and applying that list with `configure({ extensions: selected })` (not `add`: defaults keep their positions, so an `add` list does not show the effective order), after any approval extension, so no question is asked inside a sandbox. **Composition limit (host-owned ordering):** the sandbox must be the last wrap of each recorded tool. A wrap selected after it (for example `createConvertingRead` on `read`) replaces the recorder's `execute`, and that call runs for real. Pi 1.1 has no public seam that tells the executing phase which composed tool it runs, so the sandbox cannot refuse this at call time. The host checks the order at setup with `assertSandboxLast(selected, sandbox)` over the COMPLETE selected list, the same array it applies with `configure({ extensions: selected })` (`@hachej/boring-agent/sandbox`); it throws on a later wrap of a recorded tool. Nothing in Pi calls it, so a host that composes extensions without it has no protection against that ordering. The gap is recorded in [docs/upstream/pi-hookapi-composed-tool.md](../../docs/upstream/pi-hookapi-composed-tool.md). The name gate in `beforeTool` is public and holds at call time: a call to a tool the sandbox does not name is refused.
76
+
75
77
  `createApprovalExtension({ name?, tools: { bash: true, write_note: { summarize, when } } })` is the same gate as an opt-in native extension: it brings no tools, only native `wraps` that decorate whichever tool of each name wins in a conversation, including Pi's stock `bash`, `write` and `edit`. Pi applies wraps only where the extension is selected, so the gate is per conversation: `conversation.configure({ extensions: { add: [approvals] } })` turns it on and `remove` turns it off from the next call. `when(args, call)` lets a rule ask only for some calls (for example any method but GET); `summarize(args, call)` writes the card's headline (default: the tool name and its arguments). `call` is `{ conversationId, callId, toolName }`, so a host with one conversation per site or project can name it in the headline. Approval extensions compose: a tool already gated is not gated again, so the first selected rule for a tool decides. `requireApproval(tool, { summarize, when? })` remains the building block for a tool you define yourself.
76
78
 
77
79
  Code mode: gated tools are native tools and must not be exposed to a code-mode sandbox, whose nested calls cannot reach a person. Give `run_code` the read tools only and send changes through the gated native tool. `isApprovalGated(tool)` lets a host filter automatically.
@@ -132,6 +134,8 @@ Choose the seam by how the text changes and who should see it:
132
134
 
133
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`.
134
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`.
138
+
135
139
  ## Native questions
136
140
 
137
141
  `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.
@@ -3,6 +3,9 @@ import { Type } from '@earendil-works/pi-ai';
3
3
  import type { Context } from '@earendil-works/chord';
4
4
  import type { ResourceAccess, ResourceLocator } from '@hachej/boring-files';
5
5
  import type { WorkspaceBinding, WorkspaceResolver } from './workspaces.js';
6
+ import type { ViewOpenOptions } from './view-open.js';
7
+ export { answerViewOpen, parseViewOpenAnswer, VIEW_OPEN_TIMEOUT_MS } from './view-open.js';
8
+ export type { ViewOpenAnswer, ViewOpenOptions, ViewOpenOutcome } from './view-open.js';
6
9
  export declare const ARTIFACT_TYPES: readonly ["markdown", "html", "svg", "code", "canvas"];
7
10
  export type ArtifactType = typeof ARTIFACT_TYPES[number];
8
11
  /**
@@ -42,6 +45,12 @@ export interface PresentToolOptions {
42
45
  readonly resolveAccess?: (api: ToolExecutionApi, context: Context) => ResourceAccess | Promise<ResourceAccess>;
43
46
  /** Largest file shown, in bytes. Defaults to 256 KiB. */
44
47
  readonly maxBytes?: number;
48
+ /**
49
+ * Opt-in: wait until a page has mounted the presented revision (`answerViewOpen`, the chat transport's `?op=answer`) and report
50
+ * `opened`, `refused`, `unconfirmed` (no answer in time) or `viewer: 'none'` (no page watches the conversation). Absent: the result
51
+ * is the file kept, as before, with no wait.
52
+ */
53
+ readonly ack?: ViewOpenOptions;
45
54
  }
46
55
  /**
47
56
  * `present(path)`: show a workspace file to the person as a card and a side panel. The agent writes the file with its ordinary file
package/dist/artifacts.js CHANGED
@@ -2,6 +2,8 @@ import { defineTool } from '@earendil-works/pi-durable';
2
2
  import { Type } from '@earendil-works/pi-ai';
3
3
  import { workspacePath } from '@hachej/boring-files/file-types';
4
4
  import { asWorkspaceResolver, workspaceFor } from './workspaces.js';
5
+ import { PRESENT_TOOL, awaitViewOpen } from './view-open.js';
6
+ export { answerViewOpen, parseViewOpenAnswer, VIEW_OPEN_TIMEOUT_MS } from './view-open.js';
5
7
  /*
6
8
  * Artifacts: substantial, self-contained content (a document, an HTML page, an SVG, code) that a chat shows as a card
7
9
  * and a side panel instead of a wall of text. An artifact is a file of the workspace: the agent writes it with its ordinary
@@ -78,6 +80,15 @@ const PRESENTED = Object.assign(Object.create(null), {
78
80
  svg: { type: 'svg', mediaType: 'image/svg+xml' }, tldraw: { type: 'canvas', mediaType: 'application/vnd.tldraw+json' },
79
81
  });
80
82
  const LANGUAGE_OF = Object.assign(Object.create(null), { js: 'javascript', mjs: 'javascript', ts: 'typescript', py: 'python', json: 'json', css: 'css', sql: 'sql', yaml: 'yaml', yml: 'yaml', sh: 'shell', rs: 'rust', go: 'go', java: 'java', csv: 'csv' });
83
+ /** The `present` result fields for a view outcome (see ./view-open.ts): `opened` true, false (refused or no viewer) or 'unconfirmed'. */
84
+ function viewFields(outcome) {
85
+ switch (outcome.kind) {
86
+ case 'opened': return { opened: true, viewer: outcome.viewer };
87
+ case 'refused': return { opened: false, refused: outcome.reason };
88
+ case 'unconfirmed': return { opened: 'unconfirmed', timeoutMs: outcome.timeoutMs };
89
+ case 'none': return { opened: false, viewer: 'none' };
90
+ }
91
+ }
81
92
  /**
82
93
  * `present(path)`: show a workspace file to the person as a card and a side panel. The agent writes the file with its ordinary file
83
94
  * tools first. The card points at the file; the file's history (the provider's) is its list of versions. Nothing is written here.
@@ -86,7 +97,7 @@ export function createPresentTool(options = {}) {
86
97
  const resolver = asWorkspaceResolver(options.workspace);
87
98
  const maxBytes = options.maxBytes ?? 256 * 1024;
88
99
  return defineTool({
89
- name: 'present',
100
+ name: PRESENT_TOOL,
90
101
  // How to use it travels with the tool, so every host that offers `present` gets the same rules without repeating them in its prompt.
91
102
  description: [
92
103
  'Show a file of the workspace to the person in a side panel instead of pasting it in your reply.',
@@ -126,9 +137,13 @@ export function createPresentTool(options = {}) {
126
137
  const kept = await files.keep(path, access);
127
138
  if (kept.kind !== 'available')
128
139
  return reply(kept);
129
- const artifact = createArtifactDescriptor({ title: path.slice(path.lastIndexOf('/') + 1), type: kind.type, mediaType: kind.mediaType, ...(kind.language ? { language: kind.language } : {}),
130
- target, revision: kept.snapshot.ref.revision });
131
- return reply({ kind: 'presented', artifact });
140
+ const describe = (revision) => createArtifactDescriptor({ title: path.slice(path.lastIndexOf('/') + 1), type: kind.type, mediaType: kind.mediaType,
141
+ ...(kind.language ? { language: kind.language } : {}), target, revision });
142
+ if (!options.ack)
143
+ return reply({ kind: 'presented', artifact: describe(kept.snapshot.ref.revision) });
144
+ // A replay reports the revision the call recorded (and the page acknowledged), even if the file has changed since.
145
+ const { outcome, revision } = await awaitViewOpen(api, context, { tool: PRESENT_TOOL, path, revision: kept.snapshot.ref.revision }, options.ack);
146
+ return reply({ kind: 'presented', artifact: describe(revision), ...viewFields(outcome) });
132
147
  },
133
148
  });
134
149
  }
@@ -5,6 +5,7 @@ export declare const CHAT_TRANSPORT: Readonly<{
5
5
  schema: "boring.chat-transport";
6
6
  version: 1;
7
7
  maxRequestBytes: 8388608;
8
+ maxContextBytes: 4096;
8
9
  maxHistoryPage: 200;
9
10
  maxPendingFrames: 100;
10
11
  heartbeatMs: 15000;
@@ -47,6 +48,13 @@ export interface ChatTransportAccess {
47
48
  * scopes the id to this conversation. Without it `?op=withdraw` is not supported. This handler never edits the inbox.
48
49
  */
49
50
  readonly abortSubmission?: (id: SubmissionId) => Promise<'aborted' | 'already_placed' | 'settled' | 'not_found'>;
51
+ /**
52
+ * Receive the screen context a browser sent with a submit (`context`, from `createRemoteChat({ requestContext })`), called after
53
+ * the request is validated and admitted and before `conversation.submit`. The value is a plain JSON object of at most
54
+ * `CHAT_TRANSPORT.maxContextBytes`; anything else is `400 invalid-request`. The host stores it by conversation id and renders
55
+ * it in `createPromptContext({ context })`; this handler keeps nothing. Without it the `context` field is ignored.
56
+ */
57
+ readonly requestContext?: (value: Readonly<Record<string, unknown>>) => void | Promise<void>;
50
58
  /** Refuse an effect call. Reading stays governed by `authenticate` and `project`. */
51
59
  readonly allow?: (operation: 'submit' | 'abort' | 'answer' | 'withdraw' | 'configure') => boolean | Promise<boolean>;
52
60
  }
@@ -1,6 +1,6 @@
1
1
  import { RequestGuardError, hasJsonContentType, readJsonBody } from '@hachej/boring-files/request-guard';
2
2
  /** Wire protocol shared with the browser client in `@hachej/boring-ui-kit/remote-chat`. */
3
- export const CHAT_TRANSPORT = Object.freeze({ schema: 'boring.chat-transport', version: 1, maxRequestBytes: 8_388_608, maxHistoryPage: 200, maxPendingFrames: 100, heartbeatMs: 15_000 });
3
+ export const CHAT_TRANSPORT = Object.freeze({ schema: 'boring.chat-transport', version: 1, maxRequestBytes: 8_388_608, maxContextBytes: 4096, maxHistoryPage: 200, maxPendingFrames: 100, heartbeatMs: 15_000 });
4
4
  /** `no-transform` keeps proxies from compressing or rewriting responses; the watch stream adds `x-accel-buffering: no` so nginx does not buffer it. */
5
5
  const headers = { 'cache-control': 'no-store, no-transform', 'x-content-type-options': 'nosniff' };
6
6
  const failure = (status, reason) => Response.json({ schema: CHAT_TRANSPORT.schema, version: CHAT_TRANSPORT.version, reason }, { status, headers });
@@ -12,6 +12,14 @@ function content(value) {
12
12
  return Array.isArray(value) && value.length > 0 && value.every(part => part !== null && typeof part === 'object'
13
13
  && ((part.type === 'text' && typeof part.text === 'string') || (part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string')));
14
14
  }
15
+ /** The submit `context` field: absent, or a plain JSON object of at most `maxContextBytes` once serialized. */
16
+ function contextValue(value) {
17
+ if (value === undefined)
18
+ return true;
19
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
20
+ return false;
21
+ return encoder.encode(JSON.stringify(value)).length <= CHAT_TRANSPORT.maxContextBytes;
22
+ }
15
23
  async function body(request) {
16
24
  const value = await readJsonBody(request, CHAT_TRANSPORT.maxRequestBytes, request.signal);
17
25
  if (value === null || typeof value !== 'object' || Array.isArray(value))
@@ -207,7 +215,8 @@ export function createChatTransportHandler(options) {
207
215
  if (request.method === 'POST' && operation === 'submit') {
208
216
  const input = await body(request);
209
217
  if (typeof input['requestId'] !== 'string' || !input['requestId'] || !content(input['content'])
210
- || (input['whenBusy'] !== undefined && typeof input['whenBusy'] !== 'string'))
218
+ || (input['whenBusy'] !== undefined && typeof input['whenBusy'] !== 'string')
219
+ || (access.requestContext && !contextValue(input['context'])))
211
220
  return failure(400, 'invalid-request');
212
221
  let prepared = input['content'];
213
222
  if (access.prepareInput) {
@@ -218,6 +227,10 @@ export function createChatTransportHandler(options) {
218
227
  }
219
228
  if (!await allowed('submit') || access.revoked?.aborted)
220
229
  return failure(403, 'not-authorized');
230
+ if (access.requestContext && input['context'] !== undefined)
231
+ await access.requestContext(input['context']);
232
+ if (access.revoked?.aborted)
233
+ return failure(403, 'not-authorized');
221
234
  const submission = await conversation.submit({ type: 'input', requestId: input['requestId'], content: prepared,
222
235
  ...(input['whenBusy'] === undefined ? {} : { whenBusy: input['whenBusy'] }) }, context);
223
236
  return json({ submissionId: submission.id });
@@ -15,9 +15,18 @@ export interface CallBinding {
15
15
  export declare function callBinding(tx: Tx, conversation: Conversation, id: string): Promise<CallBinding>;
16
16
  /** The binding key of the calling tool task (its assistant entry and call id). */
17
17
  export declare function ownBindingKey(tx: Tx, api: Pick<ToolExecutionApi, 'taskId' | 'callId'>): Promise<string>;
18
+ /**
19
+ * A deadline on the wait (ms since the epoch): at `at`, `expire` settles the document in a background commit and returns its value.
20
+ * It is stored state, so a re-attached wait keeps the same `at`.
21
+ */
22
+ export interface WaitDeadline<T> {
23
+ readonly at: number;
24
+ readonly expire: (tx: Tx) => Promise<Readonly<T> | null | undefined>;
25
+ }
18
26
  /**
19
27
  * Waits until `settled(value)` holds for the document `key` of `family`, without holding a model request. When the call is aborted,
20
28
  * `cancel` runs in a background commit (`label` names the waiting thing in errors) (the answer path also refuses a call whose task has ended) and the abort is rethrown.
29
+ * With a `deadline`, the wait ends when `expire` has settled the document (the value it returns is the result).
21
30
  */
22
- export declare function waitForDocument<T extends JsonObject>(api: ToolExecutionApi, family: ConversationDocFamilyToken<T, null>, key: string, context: Context, settled: (value: Readonly<T> | null | undefined) => boolean, cancel: (tx: Tx) => Promise<void>, label: string): Promise<Readonly<T> | null | undefined>;
31
+ export declare function waitForDocument<T extends JsonObject>(api: ToolExecutionApi, family: ConversationDocFamilyToken<T, null>, key: string, context: Context, settled: (value: Readonly<T> | null | undefined) => boolean, cancel: (tx: Tx) => Promise<void>, label: string, deadline?: WaitDeadline<T>): Promise<Readonly<T> | null | undefined>;
23
32
  //# sourceMappingURL=durable-wait.d.ts.map
@@ -44,13 +44,15 @@ export async function ownBindingKey(tx, api) {
44
44
  /**
45
45
  * Waits until `settled(value)` holds for the document `key` of `family`, without holding a model request. When the call is aborted,
46
46
  * `cancel` runs in a background commit (`label` names the waiting thing in errors) (the answer path also refuses a call whose task has ended) and the abort is rethrown.
47
+ * With a `deadline`, the wait ends when `expire` has settled the document (the value it returns is the result).
47
48
  */
48
- export async function waitForDocument(api, family, key, context, settled, cancel, label) {
49
+ export async function waitForDocument(api, family, key, context, settled, cancel, label, deadline) {
49
50
  const watch = await api.watchDoc(family, api.conversationId, key, context);
50
51
  if (!watch)
51
52
  throw new Error(`${label} record is missing`);
52
53
  const signal = context.abortSignal;
53
54
  let final = watch.value;
55
+ let timer;
54
56
  try {
55
57
  if (!settled(final)) {
56
58
  final = await new Promise((resolve, reject) => {
@@ -58,6 +60,8 @@ export async function waitForDocument(api, family, key, context, settled, cancel
58
60
  return reject(signal.reason ?? new Error(`${label} cancelled`));
59
61
  signal?.addEventListener('abort', () => reject(signal.reason ?? new Error(`${label} cancelled`)), { once: true });
60
62
  void watch.closed.then(() => reject(new Error(`${label} observation closed`)));
63
+ if (deadline)
64
+ timer = setTimeout(() => { void api.commit(deadline.expire, BACKGROUND_CONTEXT).then(value => resolve(value), reject); }, Math.max(0, deadline.at - Date.now()));
61
65
  watch.start(async (value) => { if (settled(value))
62
66
  resolve(value); });
63
67
  });
@@ -73,6 +77,7 @@ export async function waitForDocument(api, family, key, context, settled, cancel
73
77
  throw error;
74
78
  }
75
79
  finally {
80
+ clearTimeout(timer);
76
81
  await watch.stop();
77
82
  }
78
83
  return final;
@@ -0,0 +1,61 @@
1
+ import type { Extension } from '@earendil-works/pi-durable';
2
+ import type { JsonValue } from '@earendil-works/chord';
3
+ /** Key under a recorded call's result details: `{ simulated: true, tool, arguments }`. A caller reads it back with `simulatedCalls`. */
4
+ export declare const SANDBOX_DETAILS = "boring.sandbox";
5
+ /** What the host declares about one tool. An unset field is not declared: a call is recorded unless the host declares it safe. */
6
+ export interface SandboxFlags {
7
+ readonly mutates?: boolean;
8
+ readonly writes?: boolean;
9
+ readonly sandboxSafe?: boolean;
10
+ }
11
+ export interface SandboxOptions {
12
+ /** Native extension name. Default `boring.sandbox`. */
13
+ readonly name?: string;
14
+ /** Every tool this sandbox covers. A call to any other name is blocked, including a tool added to the run later. */
15
+ readonly tools: readonly string[];
16
+ /** Host flags per tool name (for example the app's tools.json). */
17
+ readonly flags?: Readonly<Record<string, SandboxFlags>>;
18
+ /** The text the model receives for a recorded call. Default: `defaultAck`. */
19
+ readonly simulate?: (toolName: string, args: JsonValue) => string;
20
+ }
21
+ /** The acknowledgement a recorded call returns when the host does not supply its own. */
22
+ export declare const defaultAck: (toolName: string) => string;
23
+ /** `run` only when the host declares the tool safe (`sandboxSafe`, or `mutates: false` / `writes: false`). Any mutating flag records. */
24
+ export declare function sandboxDecision(flags: SandboxFlags | undefined): 'run' | 'record';
25
+ /** One call the sandbox recorded instead of running. */
26
+ export interface SimulatedCall {
27
+ readonly toolCallId: string;
28
+ readonly toolName: string;
29
+ readonly arguments: JsonValue;
30
+ }
31
+ /** The recorded calls among a conversation's tool results, in order (the results' `details` carry the record). */
32
+ export declare function simulatedCalls(results: readonly {
33
+ readonly toolCallId: string;
34
+ readonly toolName: string;
35
+ readonly details?: unknown;
36
+ }[]): SimulatedCall[];
37
+ /**
38
+ * A native extension that makes the named tools fail closed in a sandbox. Its wraps replace each tool the host flags as
39
+ * mutating (or does not declare safe) with a recorder, so the call is never executed and the model gets an acknowledgement.
40
+ * Its `beforeTool` hook blocks every call whose name is not in `tools`, so a tool added mid-run (or another extension's)
41
+ * cannot run unnamed. It brings no tools and changes nothing unless a conversation selects it: include it in the full
42
+ * selected list and apply that list with `configure({ extensions: selected })`. (`add` keeps the defaults' positions, so it
43
+ * does not give the effective order.)
44
+ *
45
+ * Composition limit (owned by the host): the sandbox must be the last wrap of each recorded tool. A wrap selected after it
46
+ * replaces the recorder's `execute`, and the replaced call runs for real. Pi 1.1 exposes no public seam that tells the
47
+ * executing phase which composed tool it runs, so this module cannot verify the order at call time. Use
48
+ * `assertSandboxLast` at setup, over the COMPLETE selected list (the array passed to `configure({ extensions: selected })`, or
49
+ * the effective list of a `defineAgent`), not over an `add` list. The upstream request is in
50
+ * docs/upstream/pi-hookapi-composed-tool.md.
51
+ */
52
+ export declare function createSandboxExtension(options: SandboxOptions): Extension;
53
+ /**
54
+ * Setup-time check over the public `Extension.wraps` shape. Throws when an extension selected after `sandbox` wraps a tool the
55
+ * sandbox records, because that wrap would replace the recorder. `selected` must be the COMPLETE effective list, in the order
56
+ * the conversation applies it (the same array passed to `configure({ extensions: selected })`). A partial `add` list
57
+ * does not show the effective order, because defaults keep their positions. It is a host check, not a Harness guard:
58
+ * nothing in Pi calls it, so the host must call it wherever it composes that list.
59
+ */
60
+ export declare function assertSandboxLast(selected: readonly Extension[], sandbox: Extension): void;
61
+ //# sourceMappingURL=sandbox.d.ts.map
@@ -0,0 +1,75 @@
1
+ import { defineExtension, hook, ToolTask } from '@earendil-works/pi-durable';
2
+ /** Key under a recorded call's result details: `{ simulated: true, tool, arguments }`. A caller reads it back with `simulatedCalls`. */
3
+ export const SANDBOX_DETAILS = 'boring.sandbox';
4
+ /** The acknowledgement a recorded call returns when the host does not supply its own. */
5
+ export const defaultAck = (toolName) => `Simulated: ${toolName} was recorded and not run. Nothing was changed.`;
6
+ /** `run` only when the host declares the tool safe (`sandboxSafe`, or `mutates: false` / `writes: false`). Any mutating flag records. */
7
+ export function sandboxDecision(flags) {
8
+ if (flags?.mutates === true || flags?.writes === true)
9
+ return 'record';
10
+ return flags?.sandboxSafe === true || flags?.mutates === false || flags?.writes === false ? 'run' : 'record';
11
+ }
12
+ /** The recorded calls among a conversation's tool results, in order (the results' `details` carry the record). */
13
+ export function simulatedCalls(results) {
14
+ return results.flatMap(result => {
15
+ const record = result.details?.[SANDBOX_DETAILS];
16
+ return record?.simulated === true ? [{ toolCallId: result.toolCallId, toolName: result.toolName, arguments: record.arguments ?? null }] : [];
17
+ });
18
+ }
19
+ /** A tool that never runs: it returns the acknowledgement and records the call in its result details. Replay-safe, as nothing executes. */
20
+ const recorded = (tool, simulate) => ({
21
+ ...tool,
22
+ replay: 'safe',
23
+ execute: async (args) => ({
24
+ content: [{ type: 'text', text: simulate(tool.name, args) }],
25
+ details: { [SANDBOX_DETAILS]: { simulated: true, tool: tool.name, arguments: args } },
26
+ }),
27
+ });
28
+ /**
29
+ * A native extension that makes the named tools fail closed in a sandbox. Its wraps replace each tool the host flags as
30
+ * mutating (or does not declare safe) with a recorder, so the call is never executed and the model gets an acknowledgement.
31
+ * Its `beforeTool` hook blocks every call whose name is not in `tools`, so a tool added mid-run (or another extension's)
32
+ * cannot run unnamed. It brings no tools and changes nothing unless a conversation selects it: include it in the full
33
+ * selected list and apply that list with `configure({ extensions: selected })`. (`add` keeps the defaults' positions, so it
34
+ * does not give the effective order.)
35
+ *
36
+ * Composition limit (owned by the host): the sandbox must be the last wrap of each recorded tool. A wrap selected after it
37
+ * replaces the recorder's `execute`, and the replaced call runs for real. Pi 1.1 exposes no public seam that tells the
38
+ * executing phase which composed tool it runs, so this module cannot verify the order at call time. Use
39
+ * `assertSandboxLast` at setup, over the COMPLETE selected list (the array passed to `configure({ extensions: selected })`, or
40
+ * the effective list of a `defineAgent`), not over an `add` list. The upstream request is in
41
+ * docs/upstream/pi-hookapi-composed-tool.md.
42
+ */
43
+ export function createSandboxExtension(options) {
44
+ const covered = new Set(options.tools);
45
+ const recordOnly = new Set(options.tools.filter(name => sandboxDecision(options.flags?.[name]) === 'record'));
46
+ const simulate = options.simulate ?? ((toolName) => defaultAck(toolName));
47
+ const wraps = [...recordOnly].map(name => ({ tool: name, wrap: (tool) => recorded(tool, simulate) }));
48
+ return defineExtension({
49
+ name: options.name ?? 'boring.sandbox',
50
+ wraps,
51
+ hooks: [hook(ToolTask, {
52
+ beforeTool: call => covered.has(call.name) ? undefined : { block: `Not run: ${call.name} is not covered by the sandbox.` },
53
+ })],
54
+ });
55
+ }
56
+ /**
57
+ * Setup-time check over the public `Extension.wraps` shape. Throws when an extension selected after `sandbox` wraps a tool the
58
+ * sandbox records, because that wrap would replace the recorder. `selected` must be the COMPLETE effective list, in the order
59
+ * the conversation applies it (the same array passed to `configure({ extensions: selected })`). A partial `add` list
60
+ * does not show the effective order, because defaults keep their positions. It is a host check, not a Harness guard:
61
+ * nothing in Pi calls it, so the host must call it wherever it composes that list.
62
+ */
63
+ export function assertSandboxLast(selected, sandbox) {
64
+ const at = selected.indexOf(sandbox);
65
+ if (at < 0)
66
+ return;
67
+ const recorded = new Set((sandbox.wraps ?? []).flatMap(wrap => 'tool' in wrap ? [wrap.tool] : []));
68
+ for (const later of selected.slice(at + 1)) {
69
+ for (const wrap of later.wraps ?? []) {
70
+ if ('tool' in wrap && recorded.has(wrap.tool)) {
71
+ throw new Error(`${later.name} wraps ${wrap.tool} after ${sandbox.name}; the sandbox must be the last wrap of each recorded tool.`);
72
+ }
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,63 @@
1
+ import type { Conversation, ToolExecutionApi } from '@earendil-works/pi-durable';
2
+ import type { Context } from '@earendil-works/chord';
3
+ import type { AskUserAnswer } from './ask-user.js';
4
+ /** How long a view may take to be confirmed by a page before the result says `unconfirmed` (a UI ack, not a person's decision). */
5
+ export declare const VIEW_OPEN_TIMEOUT_MS = 15000;
6
+ /** The tool whose calls the view-open record belongs to, unless a host renamed `present`. */
7
+ export declare const PRESENT_TOOL = "present";
8
+ /** What the page reports after its panel mounted the revision, or that it refused to open it. */
9
+ export type ViewOpenAnswer = {
10
+ readonly kind: 'opened';
11
+ readonly instanceId: string;
12
+ readonly path: string;
13
+ readonly revision: string;
14
+ } | {
15
+ readonly kind: 'refused';
16
+ readonly reason: string;
17
+ };
18
+ /** What `present` reports for the view: the result's `opened`, `refused` or `viewer` fields (see ./artifacts.ts). */
19
+ export type ViewOpenOutcome = {
20
+ readonly kind: 'opened';
21
+ readonly viewer: string;
22
+ } | {
23
+ readonly kind: 'refused';
24
+ readonly reason: string;
25
+ } | {
26
+ readonly kind: 'unconfirmed';
27
+ readonly timeoutMs: number;
28
+ } | {
29
+ readonly kind: 'none';
30
+ };
31
+ export interface ViewOpenOptions {
32
+ /** Default `VIEW_OPEN_TIMEOUT_MS`. */
33
+ readonly timeoutMs?: number;
34
+ /** Whether a page watches this conversation. False: `none` at once, nothing is waited for. Absent: the timeout bounds the wait. */
35
+ readonly viewers?: (api: ToolExecutionApi, context: Context) => boolean | Promise<boolean>;
36
+ }
37
+ /** Strict shape of the page's answer. Returns undefined for anything else. */
38
+ export declare function parseViewOpenAnswer(value: unknown): ViewOpenAnswer | undefined;
39
+ /**
40
+ * Records the pending view of a `present` call, then waits for the page's acknowledgement or the deadline. The deadline is stored
41
+ * with the record, so a re-attached call (after a restart) keeps the time it started with. Abort cancels the record.
42
+ * Returns the revision the record holds: on a replay after the file changed, that is the revision that was acknowledged, not the
43
+ * current one, so the result never pairs a new file revision with an old acknowledgement.
44
+ */
45
+ export declare function awaitViewOpen(api: ToolExecutionApi, context: Context, view: {
46
+ readonly tool: string;
47
+ readonly path: string;
48
+ readonly revision: string;
49
+ }, options?: ViewOpenOptions): Promise<{
50
+ readonly outcome: ViewOpenOutcome;
51
+ readonly revision: string;
52
+ }>;
53
+ /**
54
+ * Settles a pending view with the page's answer (JSON text) after the host authenticated the person. `id` is the answer ID of the
55
+ * call (`[assistantEntryId, callId]`, or a raw call ID used by one task). An `opened` answer must name the path and revision the
56
+ * call recorded; the first valid answer wins, an identical repeat is idempotent, any other answer after that is `conflict`.
57
+ * Returns the `ask_user` outcome kinds, so one chat transport `answer` can serve questions, browser tasks and views.
58
+ */
59
+ export declare function answerViewOpen(conversation: Conversation, id: string, answer: string, options?: {
60
+ readonly tool?: string;
61
+ readonly context?: Context;
62
+ }): Promise<AskUserAnswer>;
63
+ //# sourceMappingURL=view-open.d.ts.map
@@ -0,0 +1,146 @@
1
+ // The acknowledgement that a `present` call's file is on screen (the opt-in `ack` of `createPresentTool`, ./artifacts.ts). It is the
2
+ // same durable wait as `ask_user` and browser tasks (./durable-wait.ts): a conversation document keyed by the call's
3
+ // `[assistantEntryId, callId]` holds the pending view, the page answers after its panel has mounted the revision, and a stored
4
+ // deadline ends the wait. It is a sibling of ./browser-task.ts, not a second channel: the page answers through the chat transport's
5
+ // `?op=answer`. Presentation outcomes (`createPresentationTool`, ./presentation.ts) are a different contract and stay separate.
6
+ import { defineDocFamily } from '@earendil-works/pi-durable';
7
+ import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
8
+ import { AmbiguousCall, UnknownCall, callBinding, ownBindingKey, waitForDocument } from './durable-wait.js';
9
+ /** How long a view may take to be confirmed by a page before the result says `unconfirmed` (a UI ack, not a person's decision). */
10
+ export const VIEW_OPEN_TIMEOUT_MS = 15_000;
11
+ /** The tool whose calls the view-open record belongs to, unless a host renamed `present`. */
12
+ export const PRESENT_TOOL = 'present';
13
+ /** One member per assistant entry and tool call, in the calling conversation. */
14
+ const documents = defineDocFamily({
15
+ kind: 'boring.view-open', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', family: true,
16
+ initial: () => ({ view: null }),
17
+ });
18
+ const MAX_REASON = 200;
19
+ const MAX_ID = 128;
20
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
21
+ /** Strict shape of the page's answer. Returns undefined for anything else. */
22
+ export function parseViewOpenAnswer(value) {
23
+ if (!isObject(value))
24
+ return undefined;
25
+ if (value['kind'] === 'opened' && Object.keys(value).every(key => ['kind', 'instanceId', 'path', 'revision'].includes(key))) {
26
+ const { instanceId, path, revision } = value;
27
+ if (typeof instanceId !== 'string' || !instanceId || instanceId.length > MAX_ID)
28
+ return undefined;
29
+ if (typeof path !== 'string' || !path || path.length > 512)
30
+ return undefined;
31
+ if (typeof revision !== 'string' || !revision || revision.length > 256)
32
+ return undefined;
33
+ return { kind: 'opened', instanceId, path, revision };
34
+ }
35
+ if (value['kind'] === 'refused' && Object.keys(value).every(key => ['kind', 'reason'].includes(key))) {
36
+ const { reason } = value;
37
+ if (typeof reason !== 'string' || !reason || reason.length > MAX_REASON)
38
+ return undefined;
39
+ return { kind: 'refused', reason };
40
+ }
41
+ return undefined;
42
+ }
43
+ /**
44
+ * Records the pending view of a `present` call, then waits for the page's acknowledgement or the deadline. The deadline is stored
45
+ * with the record, so a re-attached call (after a restart) keeps the time it started with. Abort cancels the record.
46
+ * Returns the revision the record holds: on a replay after the file changed, that is the revision that was acknowledged, not the
47
+ * current one, so the result never pairs a new file revision with an old acknowledgement.
48
+ */
49
+ export async function awaitViewOpen(api, context, view, options = {}) {
50
+ const timeoutMs = options.timeoutMs ?? VIEW_OPEN_TIMEOUT_MS;
51
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
52
+ throw new TypeError('The view-open timeout must be a positive integer of milliseconds');
53
+ if (options.viewers && !(await options.viewers(api, context)))
54
+ return { outcome: { kind: 'none' }, revision: view.revision };
55
+ const { key, startedAt, duration } = await api.commit(async (tx) => {
56
+ const key = await ownBindingKey(tx, api);
57
+ const document = await tx.doc(documents, api.conversationId, key, null);
58
+ if (!document.view)
59
+ document.view = { tool: view.tool, taskId: api.taskId, path: view.path, revision: view.revision, startedAt: Date.now(), timeoutMs, state: { kind: 'pending' } };
60
+ if (document.view.taskId !== api.taskId)
61
+ throw new Error('View-open identity belongs to another task');
62
+ return { key, startedAt: document.view.startedAt, duration: document.view.timeoutMs };
63
+ }, context);
64
+ const expire = async (tx) => {
65
+ const document = await tx.doc(documents, api.conversationId, key, null);
66
+ if (document.view?.state.kind === 'pending')
67
+ document.view.state = { kind: 'unconfirmed' };
68
+ return { view: document.view ? { ...document.view, state: { ...document.view.state } } : null };
69
+ };
70
+ const final = await waitForDocument(api, documents, key, context, value => value?.view?.state.kind !== 'pending', async (tx) => {
71
+ const document = await tx.doc(documents, api.conversationId, key, null);
72
+ if (document.view?.state.kind === 'pending')
73
+ document.view.state = { kind: 'cancelled' };
74
+ }, 'View open', { at: startedAt + duration, expire });
75
+ const state = final?.view?.state;
76
+ const revision = final?.view?.revision ?? view.revision;
77
+ if (state?.kind === 'opened')
78
+ return { outcome: { kind: 'opened', viewer: state.viewer }, revision };
79
+ if (state?.kind === 'refused')
80
+ return { outcome: { kind: 'refused', reason: state.reason }, revision };
81
+ if (state?.kind === 'unconfirmed')
82
+ return { outcome: { kind: 'unconfirmed', timeoutMs: duration }, revision };
83
+ throw new Error('View open cancelled');
84
+ }
85
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
86
+ /**
87
+ * Settles a pending view with the page's answer (JSON text) after the host authenticated the person. `id` is the answer ID of the
88
+ * call (`[assistantEntryId, callId]`, or a raw call ID used by one task). An `opened` answer must name the path and revision the
89
+ * call recorded; the first valid answer wins, an identical repeat is idempotent, any other answer after that is `conflict`.
90
+ * Returns the `ask_user` outcome kinds, so one chat transport `answer` can serve questions, browser tasks and views.
91
+ */
92
+ export async function answerViewOpen(conversation, id, answer, options = {}) {
93
+ const tool = options.tool ?? PRESENT_TOOL;
94
+ if (typeof id !== 'string' || !id || id.length > 1024)
95
+ return { kind: 'unknown-question' };
96
+ if (typeof answer !== 'string' || answer.length > 4096)
97
+ return { kind: 'denied', reason: 'The answer must be JSON text of at most 4096 characters' };
98
+ let value;
99
+ try {
100
+ value = JSON.parse(answer);
101
+ }
102
+ catch {
103
+ return { kind: 'denied', reason: 'The answer is not JSON' };
104
+ }
105
+ const parsed = parseViewOpenAnswer(value);
106
+ if (!parsed)
107
+ return { kind: 'denied', reason: 'The answer must be { kind: "opened", instanceId, path, revision } or { kind: "refused", reason }' };
108
+ const next = parsed.kind === 'opened' ? { kind: 'opened', viewer: parsed.instanceId } : { kind: 'refused', reason: parsed.reason };
109
+ try {
110
+ return await conversation.commit(async (tx) => {
111
+ const binding = await callBinding(tx, conversation, id);
112
+ const task = await tx.task(binding.taskId);
113
+ if (!task)
114
+ return { kind: 'conflict', reason: 'The view is unavailable' };
115
+ const document = await tx.doc(documents, conversation.id, binding.key, null);
116
+ // Throwing rolls back the empty draft an unknown or foreign call would leave behind.
117
+ if (!document.view || document.view.tool !== tool)
118
+ throw new UnknownCall();
119
+ const record = document.view;
120
+ if (record.taskId !== task.id)
121
+ return { kind: 'conflict', reason: 'The view belongs to another task' };
122
+ if (record.state.kind !== 'pending')
123
+ return same(record.state, next) ? { kind: 'answered' } : { kind: 'conflict', reason: 'The view was already decided' };
124
+ if (task.abortRequested || task.state.status === 'terminal') {
125
+ record.state = { kind: 'cancelled' };
126
+ return { kind: 'conflict', reason: 'The view was cancelled' };
127
+ }
128
+ if (Date.now() >= record.startedAt + record.timeoutMs) {
129
+ record.state = { kind: 'unconfirmed' };
130
+ return { kind: 'conflict', reason: 'The view was not confirmed in time' };
131
+ }
132
+ if (parsed.kind === 'opened' && (parsed.path.replace(/^\.\//, '') !== record.path || parsed.revision !== record.revision)) {
133
+ return { kind: 'denied', reason: 'The acknowledgement names another file or revision than the one presented' };
134
+ }
135
+ record.state = next;
136
+ return { kind: 'answered' };
137
+ }, options.context ?? BACKGROUND_CONTEXT);
138
+ }
139
+ catch (error) {
140
+ if (error instanceof AmbiguousCall)
141
+ return { kind: 'conflict', reason: 'Use the assistant-entry-bound call ID' };
142
+ if (error instanceof UnknownCall)
143
+ return { kind: 'unknown-question' };
144
+ throw error;
145
+ }
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -54,6 +54,10 @@
54
54
  "types": "./dist/file-convert.d.ts",
55
55
  "import": "./dist/file-convert.js"
56
56
  },
57
+ "./sandbox": {
58
+ "types": "./dist/sandbox.d.ts",
59
+ "import": "./dist/sandbox.js"
60
+ },
57
61
  "./file-types": {
58
62
  "types": "./dist/file-types.d.ts",
59
63
  "import": "./dist/file-types.js"
@@ -132,11 +136,11 @@
132
136
  }
133
137
  },
134
138
  "peerDependencies": {
135
- "@earendil-works/pi-durable": "^1.1.0",
136
- "@earendil-works/pi-ai": "^1.1.0",
137
- "@earendil-works/chord": "^1.1.0",
138
- "@hachej/boring-files": "^0.2.2",
139
- "@hachej/boring-ui-kit": "^0.2.2"
139
+ "@earendil-works/pi-durable": "~1.1.0",
140
+ "@earendil-works/pi-ai": "~1.1.0",
141
+ "@earendil-works/chord": "~1.1.0",
142
+ "@hachej/boring-files": "^0.2.4",
143
+ "@hachej/boring-ui-kit": "^0.2.4"
140
144
  },
141
145
  "peerDependenciesMeta": {
142
146
  "@hachej/boring-files": {