@hachej/boring-agent 0.2.2 → 0.2.3
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 +3 -1
- package/dist/artifacts.d.ts +9 -0
- package/dist/artifacts.js +19 -4
- package/dist/durable-wait.d.ts +10 -1
- package/dist/durable-wait.js +6 -1
- package/dist/sandbox.d.ts +61 -0
- package/dist/sandbox.js +75 -0
- package/dist/view-open.d.ts +63 -0
- package/dist/view-open.js +146 -0
- package/package.json +10 -6
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.
|
package/dist/artifacts.d.ts
CHANGED
|
@@ -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:
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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
|
}
|
package/dist/durable-wait.d.ts
CHANGED
|
@@ -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
|
package/dist/durable-wait.js
CHANGED
|
@@ -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
|
package/dist/sandbox.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.2.3",
|
|
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": "
|
|
136
|
-
"@earendil-works/pi-ai": "
|
|
137
|
-
"@earendil-works/chord": "
|
|
138
|
-
"@hachej/boring-files": "^0.2.
|
|
139
|
-
"@hachej/boring-ui-kit": "^0.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.3",
|
|
143
|
+
"@hachej/boring-ui-kit": "^0.2.3"
|
|
140
144
|
},
|
|
141
145
|
"peerDependenciesMeta": {
|
|
142
146
|
"@hachej/boring-files": {
|