@hachej/boring-agent 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,7 +72,7 @@ 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.
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.2 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
76
 
77
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.
78
78
 
@@ -238,7 +238,7 @@ Each evidence entry must link to a completed native ToolTask, its original assis
238
238
 
239
239
  ## Asking the person
240
240
 
241
- `@hachej/boring-agent/ask-user` exports `createAskUserTool()`, a replay-safe native tool named `ask_user` with arguments `{ question, options?: string[2..6], allowFreeText? }`. The call waits on a conversation document keyed by its assistant entry and tool call ID, so no model request is held, a pending question survives a restart and stopping the conversation cancels it. A trusted host resolves it with `answerUserQuestion(conversation, askUserQuestionId(assistantEntryId, callId), answer)` (pass the scoped ID through `ChatTransportAccess.answer` after authenticating the person): the answer must be an option unless free text is allowed, at most 2000 characters; one answer wins, repeating it is idempotent. The tool result is `{"kind":"answered","answer":"..."}`. The chat transport also serves `?op=answer` and `?op=withdraw` (a queued message, by submission ID), both gated by `allow`.
241
+ `@hachej/boring-agent/ask-user` exports `createAskUserTool()`, a replay-safe native tool named `ask_user` with arguments `{ question, options?: string[2..6], allowFreeText? }`. The call waits on a conversation document keyed by its assistant entry and tool call ID, so no model request is held, a pending question survives a restart and stopping the conversation cancels it. A trusted host resolves it with `answerUserQuestion(conversation, askUserQuestionId(assistantEntryId, callId), answer)` (pass the scoped ID through `ChatTransportAccess.answer` after authenticating the person): the answer must be an option unless free text is allowed, at most 2000 characters; one answer wins, repeating it is idempotent. The tool result is `{"kind":"answered","answer":"..."}`. The chat transport also serves `?op=answer` and `?op=withdraw` (a queued message, by submission ID), both gated by `allow`. Like browser tasks and `requireApproval`-gated tools, it is `callers: ['model']`: the wait binds to the calling model entry, so Pi refuses a nested `api.executeTool` call (`tool_unavailable`) until nested bindings are supported.
242
242
 
243
243
  ## Browser tasks
244
244
 
@@ -298,7 +298,7 @@ const navigate = createPresentationTool({
298
298
  target: capturedTarget,
299
299
  prepareInput: ({ index }) => ({ index, expiresAt: Date.now() + 5000 }),
300
300
  authorize: (_input, _target, api, context) => canNavigate(api.conversationId, context),
301
- formatResult: result => ({ content: [{ type: 'text', text: JSON.stringify({ kind: result.kind }) }] }),
301
+ formatResult: result => ({ output: [{ type: 'text', text: JSON.stringify({ kind: result.kind }) }] }),
302
302
  });
303
303
  ```
304
304
 
package/dist/approval.js CHANGED
@@ -15,7 +15,7 @@ export const CANCELLED_PREFIX = 'Not run: the approval question was cancelled.';
15
15
  export const APPROVAL_DETAILS = 'boring.approval';
16
16
  const MEMO = 'boring.approval.started';
17
17
  const GATED = new WeakSet();
18
- const text = (value, isError) => ({ isError, content: [{ type: 'text', text: value }] });
18
+ const text = (value, isError) => ({ isError, output: [{ type: 'text', text: value }] });
19
19
  /**
20
20
  * Wrap a native tool so the person must approve each call before it executes. The tool keeps its name, schema and
21
21
  * description. Before `execute`, the call asks "Approve / Deny" through the ask-user mechanism (a conversation document
@@ -41,6 +41,7 @@ export function requireApproval(tool, options) {
41
41
  const gated = {
42
42
  ...tool,
43
43
  replay: 'safe',
44
+ callers: ['model'], // the question binds to the calling model entry; a nested executeTool caller has none (durable-wait.ts)
44
45
  execute: async (args, outerApi, context) => {
45
46
  let record;
46
47
  const withRecord = (value) => !record ? value
package/dist/artifacts.js CHANGED
@@ -66,7 +66,7 @@ export function parseArtifact(value) {
66
66
  return { schema: 'boring.artifact', version: 1, ...(id === undefined ? {} : { id }), title, type: type, mediaType, ...(language === undefined ? {} : { language: language }),
67
67
  target: { resource: { providerId, path }, view: parsedView }, revision, ...(ordinal === undefined ? {} : { ordinal }) };
68
68
  }
69
- const reply = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
69
+ const reply = (value) => ({ output: [{ type: 'text', text: JSON.stringify(value) }] });
70
70
  const decode = (bytes) => { try {
71
71
  return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
72
72
  }
package/dist/ask-user.js CHANGED
@@ -35,12 +35,14 @@ export function createAskUserTool(options = {}) {
35
35
  allowFreeText: Type.Optional(Type.Boolean()),
36
36
  }, { additionalProperties: false }),
37
37
  replay: 'safe',
38
+ // The wait binds to the calling model entry and call id; a nested `api.executeTool` caller has neither (see durable-wait.ts).
39
+ callers: ['model'],
38
40
  execute: async (args, api, context) => {
39
41
  const problem = refusal(args.options, args.allowFreeText);
40
42
  if (problem)
41
- return { isError: true, content: [{ type: 'text', text: `Invalid question: ${problem}.` }] };
43
+ return { isError: true, output: [{ type: 'text', text: `Invalid question: ${problem}.` }] };
42
44
  const answer = await askPerson(api, context, { prompt: args.question, ...(args.options === undefined ? {} : { options: args.options }), allowFreeText: args.allowFreeText === true });
43
- return { content: [{ type: 'text', text: JSON.stringify({ kind: 'answered', answer }) }] };
45
+ return { output: [{ type: 'text', text: JSON.stringify({ kind: 'answered', answer }) }] };
44
46
  },
45
47
  });
46
48
  }
@@ -31,6 +31,8 @@ export function createBrowserTaskTool(options) {
31
31
  description: options.description,
32
32
  parameters: options.parameters,
33
33
  replay: 'safe',
34
+ // The wait binds to the calling model entry and call id; a nested `api.executeTool` caller has neither (see durable-wait.ts).
35
+ callers: ['model'],
34
36
  execute: async (_args, api, context) => {
35
37
  const key = await api.commit(async (tx) => {
36
38
  const key = await ownBindingKey(tx, api);
@@ -49,7 +51,7 @@ export function createBrowserTaskTool(options) {
49
51
  const state = final?.task?.state;
50
52
  if (state?.kind !== 'answered')
51
53
  throw new Error('Browser task cancelled');
52
- return { content: [{ type: 'text', text: JSON.stringify(state.answer) }] };
54
+ return { output: [{ type: 'text', text: JSON.stringify(state.answer) }] };
53
55
  },
54
56
  });
55
57
  }
@@ -21,7 +21,7 @@ import { asWorkspaceResolver, workspaceFor } from './workspaces.js';
21
21
  const MAX_LINES = 2000; // Pi's read page: DEFAULT_MAX_LINES
22
22
  const MAX_BYTES = 50 * 1024; // Pi's read page: DEFAULT_MAX_BYTES
23
23
  const DEFAULT_MAX_FILE = 20 * 1024 * 1024;
24
- const failure = (text, code) => ({ content: [{ type: 'text', text }], isError: true, diagnostics: [{ severity: 'error', code, message: text }] });
24
+ const failure = (text, code) => ({ output: [{ type: 'text', text }], isError: true, diagnostics: [{ severity: 'error', code, message: text }] });
25
25
  const utf8Length = (text) => new TextEncoder().encode(text).length;
26
26
  /**
27
27
  * The converted text as pageable lines. A line longer than MAX_BYTES is split, on character boundaries, into segments of at most
@@ -83,7 +83,7 @@ function convertedPage(text, offset, limit) {
83
83
  diagnostics.push({ severity: 'info', code: 'truncated', message: `Showing lines ${start + 1}-${end} of ${total}${byBytes ? ` (${MAX_BYTES / 1024}KB limit)` : ''}. Use offset=${end + 1} to continue.` });
84
84
  else if (end < total)
85
85
  diagnostics.push({ severity: 'info', message: `${total - end} more lines in file. Use offset=${end + 1} to continue.` });
86
- return { content: shownText === '' ? [] : [{ type: 'text', text: shownText }], diagnostics };
86
+ return { output: shownText === '' ? [] : [{ type: 'text', text: shownText }], diagnostics };
87
87
  }
88
88
  /** Pi's `read`, extended to convert PDF, office and image files through the host's converter. An extension to select (after the extension that registers `read`, before the file guard). */
89
89
  export function createConvertingRead(options = {}) {
@@ -16,7 +16,7 @@ export const recordRevision = (api, key, revision, context) => api.commit(async
16
16
  const doc = await tx.doc(lastReadRevisions, api.conversationId);
17
17
  doc.revisions[key] = revision;
18
18
  }, context);
19
- const refusal = (text) => ({ content: [{ type: 'text', text }], isError: true });
19
+ const refusal = (text) => ({ output: [{ type: 'text', text }], isError: true });
20
20
  // The same normalisation as Pi's tool paths: a leading "@" and special spaces.
21
21
  const normalised = (path) => { const spaced = path.replace(/[  -    ]/g, ' '); return spaced.startsWith('@') ? spaced.slice(1) : spaced; };
22
22
  /** `path` as the call's env names it, and relative to `root`, or undefined when it is not lexically inside `root` (or the call has no env). Pi's path normalisation applies. */
package/dist/git.js CHANGED
@@ -38,7 +38,7 @@ export function createGitTool(repository) {
38
38
  const resolved = repository === undefined ? await workspaceFor(undefined, api, context) : undefined;
39
39
  const selected = repository ?? (resolved && 'binding' in resolved ? resolved.binding.repository : undefined);
40
40
  if (selected === undefined)
41
- return { content: [{ type: 'text', text: 'This conversation has no working repository.' }], isError: true };
41
+ return { output: [{ type: 'text', text: 'This conversation has no working repository.' }], isError: true };
42
42
  const signal = context.abortSignal;
43
43
  let value;
44
44
  switch (args.operation) {
@@ -73,7 +73,7 @@ export function createGitTool(repository) {
73
73
  value = (await selected.diff(args.before, args.after, signal)).map(change => ({ path: change.path, before: readable(change.before), after: readable(change.after) }));
74
74
  break;
75
75
  }
76
- return { content: [{ type: 'text', text: JSON.stringify({ operation: args.operation, value: value ?? null }) }] };
76
+ return { output: [{ type: 'text', text: JSON.stringify({ operation: args.operation, value: value ?? null }) }] };
77
77
  },
78
78
  });
79
79
  }
@@ -340,7 +340,7 @@ export function createOptChatMemory(options) {
340
340
  }
341
341
  }
342
342
  // ---- The extension -----------------------------------------------------------------------------------------------------
343
- const text = (value) => ({ content: [{ type: 'text', text: value }] });
343
+ const text = (value) => ({ output: [{ type: 'text', text: value }] });
344
344
  const zoomTool = defineTool({
345
345
  name: 'zoom',
346
346
  description: 'Open the line id+n of the view into the two lines of n/2 under it; n = 1 gives the message whole.',
@@ -3,7 +3,7 @@
3
3
  * native shapes produced them. New records take the current release; records written under an earlier reviewed release stay
4
4
  * readable, because the native conversation and tool shapes they refer to did not change between them.
5
5
  */
6
- export declare const NATIVE_VERSION = "pi-durable@1.1.0";
7
- export type NativeVersion = 'pi-durable@1.0.1' | typeof NATIVE_VERSION;
6
+ export declare const NATIVE_VERSION = "pi-durable@1.2.0";
7
+ export type NativeVersion = 'pi-durable@1.0.1' | 'pi-durable@1.1.0' | typeof NATIVE_VERSION;
8
8
  export declare const isNativeVersion: (value: unknown) => value is NativeVersion;
9
9
  //# sourceMappingURL=native-version.d.ts.map
@@ -3,5 +3,5 @@
3
3
  * native shapes produced them. New records take the current release; records written under an earlier reviewed release stay
4
4
  * readable, because the native conversation and tool shapes they refer to did not change between them.
5
5
  */
6
- export const NATIVE_VERSION = 'pi-durable@1.1.0';
7
- export const isNativeVersion = (value) => value === NATIVE_VERSION || value === 'pi-durable@1.0.1';
6
+ export const NATIVE_VERSION = 'pi-durable@1.2.0';
7
+ export const isNativeVersion = (value) => value === NATIVE_VERSION || value === 'pi-durable@1.1.0' || value === 'pi-durable@1.0.1';
package/dist/sandbox.js CHANGED
@@ -21,7 +21,7 @@ const recorded = (tool, simulate) => ({
21
21
  ...tool,
22
22
  replay: 'safe',
23
23
  execute: async (args) => ({
24
- content: [{ type: 'text', text: simulate(tool.name, args) }],
24
+ output: [{ type: 'text', text: simulate(tool.name, args) }],
25
25
  details: { [SANDBOX_DETAILS]: { simulated: true, tool: tool.name, arguments: args } },
26
26
  }),
27
27
  });
@@ -40,14 +40,14 @@ export function agentInstructionsSection() {
40
40
  /** Runs `run` through the conversation's environment with the arguments as one line of JSON on stdin; the output is the result. */
41
41
  async function runTool(description, env, args, context) {
42
42
  if (env === undefined || typeof env.exec !== 'function')
43
- return { content: [{ type: 'text', text: `${description.name} cannot run: this conversation has no execution environment.` }], isError: true };
43
+ return { output: [{ type: 'text', text: `${description.name} cannot run: this conversation has no execution environment.` }], isError: true };
44
44
  let output = '';
45
45
  const command = `{ ${description.run}\n} <<'${ARGS_END}'\n${JSON.stringify(args ?? {})}\n${ARGS_END}`;
46
46
  const result = await env.exec(command, { onOutput: text => { output += text; } }, context);
47
47
  if (!result.ok)
48
- return { content: [{ type: 'text', text: `${description.name} failed: ${result.error.message}${output ? `\n${output}` : ''}` }], isError: true };
48
+ return { output: [{ type: 'text', text: `${description.name} failed: ${result.error.message}${output ? `\n${output}` : ''}` }], isError: true };
49
49
  const failed = result.value.exitCode !== 0;
50
- return { content: [{ type: 'text', text: output || '(no output)' }], ...(failed ? { isError: true, diagnostics: [{ severity: 'error', message: `${description.run} exited with code ${result.value.exitCode}` }] } : {}) };
50
+ return { output: [{ type: 'text', text: output || '(no output)' }], ...(failed ? { isError: true, diagnostics: [{ severity: 'error', message: `${description.run} exited with code ${result.value.exitCode}` }] } : {}) };
51
51
  }
52
52
  /** The Harness validates each call's arguments against the agent-written schema before `run` starts (see `jsonSchemaTool`). */
53
53
  function toolOf(description) {
@@ -121,7 +121,7 @@ export function createSelfEvolution(options) {
121
121
  executionMode: 'sequential',
122
122
  execute: async (_args, api, context) => {
123
123
  const report = await reload(api.env, context);
124
- return { content: [{ type: 'text', text: report.text }], details: JSON.parse(JSON.stringify(report)) };
124
+ return { output: [{ type: 'text', text: report.text }], details: JSON.parse(JSON.stringify(report)) };
125
125
  },
126
126
  });
127
127
  const section = agentInstructionsSection();
package/dist/skills.js CHANGED
@@ -44,7 +44,7 @@ export function createSkillsExtension(name, skills) {
44
44
  replay: 'safe',
45
45
  execute: async (args) => {
46
46
  const skill = known.get(args.name);
47
- return { content: [{ type: 'text', text: skill ? skill.body : `Unknown skill "${args.name}". Available: ${[...known.keys()].join(', ')}` }] };
47
+ return { output: [{ type: 'text', text: skill ? skill.body : `Unknown skill "${args.name}". Available: ${[...known.keys()].join(', ')}` }] };
48
48
  },
49
49
  })],
50
50
  });
@@ -19,6 +19,8 @@ async function ownedBy(tx, id, owner) {
19
19
  }
20
20
  return false;
21
21
  }
22
+ // A completed v1 tool task keeps version 1 and its untagged `{ assistant, callId }` input after the upgrade; v2 model tasks are tagged.
23
+ const modelTaskShape = (version, input) => version === 1 || (version === ToolTask.definition.version && object(input) && input.kind === 'model');
22
24
  export async function readToolEvidence(harness, owner, ids, context) {
23
25
  return harness.commit(async (tx) => {
24
26
  const evidence = [];
@@ -27,7 +29,7 @@ export async function readToolEvidence(harness, owner, ids, context) {
27
29
  if (!entry?.byTaskId || entry.model?.length !== 1)
28
30
  throw new Error('Missing native tool result');
29
31
  const task = await tx.task(entry.byTaskId);
30
- if (!task || task.kind !== ToolTask.definition.name || task.version !== ToolTask.definition.version
32
+ if (!task || task.kind !== ToolTask.definition.name || !modelTaskShape(task.version, task.input)
31
33
  || task.conversationId !== entry.conversationId || task.state.status !== 'terminal'
32
34
  || task.state.outcome.status !== 'completed' || !object(task.state.outcome.result)
33
35
  || task.state.outcome.result.entryId !== id || !object(task.input)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -136,11 +136,11 @@
136
136
  }
137
137
  },
138
138
  "peerDependencies": {
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.5",
143
- "@hachej/boring-ui-kit": "^0.2.5"
139
+ "@earendil-works/pi-durable": "~1.2.0",
140
+ "@earendil-works/pi-ai": "~1.2.0",
141
+ "@earendil-works/chord": "~1.2.0",
142
+ "@hachej/boring-files": "^0.3.1",
143
+ "@hachej/boring-ui-kit": "^0.3.1"
144
144
  },
145
145
  "peerDependenciesMeta": {
146
146
  "@hachej/boring-files": {