@4fu/arbor-agent-pi 2026.1106.0 → 2026.1107.0

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
@@ -27,9 +27,11 @@ everything that takes judgment, such as git, merges and conflicts, to the agent.
27
27
  guarantees what an agent cannot guarantee for itself - the record, isolation,
28
28
  exactly-once effects, the checks, dispatch. Everything that takes judgment,
29
29
  such as git, merges and conflicts, is the agent's.
30
- - **A directory per window.** Each window gets its own directory, and a
31
- continuation keeps working in its predecessor's directory in place. It is not
32
- bound to a repository; the agent clones and pushes what it needs.
30
+ - **A shared workspace with per-window checkouts.** The default workspace is
31
+ ovnfs: one shared folder every window reads, and a window opens its own
32
+ copy-on-write checkout of the project it needs, works there and merges back
33
+ with `apply`. It falls back to a plain directory per window when ovnfs is not
34
+ installed. It is not a sandbox: ovnfs isolates files, not processes.
33
35
  - **A self-changing environment.** The model adds and changes skills, MCP
34
36
  servers and resident services itself; a `reload` validates the change and
35
37
  broadcasts it to every live window as a delta of names and descriptions.
@@ -54,7 +56,9 @@ npm install @4fu/arbor-core @4fu/arbor-journal-sqlite @4fu/arbor-notes-sqlite \
54
56
  ```
55
57
 
56
58
  `@4fu/arbor-plugin-http` is optional; it is the example plugin below.
57
- `@4fu/arbor-segmenter-jieba` is an optional peer of the journal.
59
+ `@4fu/arbor-segmenter-jieba` is an optional peer of the lexical package, and
60
+ `@4fu/arbor-search-zvec` an optional peer of the journal: absent either one, the
61
+ journal searches lexically and nothing else changes.
58
62
 
59
63
  ## A minimal host
60
64
 
@@ -118,7 +122,10 @@ window gets, and how a continuation keeps working in it, are in
118
122
  | [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
119
123
  | [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
120
124
  | [`@4fu/arbor-segmenter-jieba`](packages/segmenter-jieba) | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
121
- | [`@4fu/arbor-workspace-dir`](packages/workspace-dir) | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
125
+ | [`@4fu/arbor-lexical`](packages/lexical) | the lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order |
126
+ | [`@4fu/arbor-search-zvec`](packages/search-zvec) | the optional semantic channel: event text embedded and searched by meaning with zvec |
127
+ | [`@4fu/arbor-workspace-dir`](packages/workspace-dir) | the fallback `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
128
+ | [`@4fu/arbor-workspace-ovnfs`](packages/workspace-ovnfs) | the default `Workspace` when ovnfs is installed: one shared folder, per-window copy-on-write checkouts the model opens and applies back |
122
129
  | [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
123
130
  | [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
124
131
  | [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
@@ -151,7 +158,7 @@ deployment can see what it would be taking on before it adopts one.
151
158
  | [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
152
159
  | [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
153
160
  | [docs/memory.md](docs/memory.md) | notes, the active set, anchors, links |
154
- | [docs/workspaces.md](docs/workspaces.md) | per-window directories: isolation, continuity, what the agent does |
161
+ | [docs/workspaces.md](docs/workspaces.md) | the shared folder and per-window checkouts: isolation, continuity, what the agent does |
155
162
  | [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
156
163
  | [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
157
164
  | [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
@@ -168,6 +175,7 @@ tarball would contain ([docs/release.md](docs/release.md)).
168
175
  | `packages/errors` | the error shape every refusal uses: the rule and the next action |
169
176
  | `packages/journal-sqlite` | the default `Journal` and `BlobStore` on SQLite |
170
177
  | `packages/segmenter-jieba` | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
178
+ | `packages/lexical` | the lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order |
171
179
  | `packages/notes-sqlite` | the default `NoteStore` on SQLite, beside the journal |
172
180
  | `packages/judge-jev` | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
173
181
  | `packages/workspace-dir` | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place, and the lookup of another window's directory it contributes to the catalog |
package/backend.d.ts CHANGED
@@ -34,12 +34,25 @@ export interface PiBackendOptions {
34
34
  control?: WindowControl;
35
35
  /** The name this backend reports. */
36
36
  name?: string;
37
+ /**
38
+ * How long to wait before each retry of a transient model error. A run that
39
+ * fails with one pi classifies as transient is sent again, byte for byte, so
40
+ * the provider's cached prefix still holds
41
+ * (docs/windows.md#model-failures).
42
+ */
43
+ retryDelaysMs?: readonly number[];
44
+ /** How the backend waits between retries. Tests pass an immediate one. */
45
+ sleep?: (ms: number) => Promise<void>;
37
46
  }
47
+ /** How long to wait before each retry of a transient model error. */
48
+ export declare const DEFAULT_RETRY_DELAYS_MS: readonly number[];
38
49
  export declare class PiAgentBackend implements AgentBackend {
39
50
  readonly name: string;
40
51
  private readonly runtime;
41
52
  private readonly renderer;
42
53
  private control;
54
+ private readonly retryDelaysMs;
55
+ private readonly sleep;
43
56
  constructor(options?: PiBackendOptions);
44
57
  /**
45
58
  * Take the host's window control. A backend that runs windows in process
@@ -57,6 +70,19 @@ export declare class PiAgentBackend implements AgentBackend {
57
70
  login(request: LoginRequest): Promise<LoginResult>;
58
71
  /** Open one window and start its first turn. */
59
72
  open(request: OpenWindowRequest): Promise<WindowSession>;
73
+ /**
74
+ * The window's first turn, retried while the model service fails transiently.
75
+ *
76
+ * pi settles a failed run rather than throwing: the agent records an assistant
77
+ * message whose `stopReason` is `error`, and `isRetryableAssistantError` says
78
+ * whether that error is the transient kind. When it is, the failed message is
79
+ * dropped and the same request is sent again - the same bytes, in the same
80
+ * order, so the provider's cached prefix still holds and nothing the model
81
+ * already did is repeated. A run that fails any other way, or one that runs
82
+ * out of retries, rejects, which is how the session settles the window as
83
+ * failed for the host (docs/windows.md#model-failures).
84
+ */
85
+ private runWithRetry;
60
86
  /**
61
87
  * One direct tool call. The call is recorded at its event before it runs, and
62
88
  * the call itself goes through the host's tool groups - the same path a direct
package/backend.js CHANGED
@@ -16,20 +16,27 @@
16
16
  */
17
17
  import { Agent } from "@earendil-works/pi-agent-core";
18
18
  import { builtinModels } from "@earendil-works/pi-ai/providers/all";
19
+ import { isRetryableAssistantError } from "@earendil-works/pi-ai";
19
20
  import { arborError } from "@4fu/arbor-errors";
20
21
  import { Renderer } from "@4fu/arbor-prompt";
21
22
  import { PiWindowSession } from "./session.js";
22
23
  import { piTool } from "./tools.js";
24
+ /** How long to wait before each retry of a transient model error. */
25
+ export const DEFAULT_RETRY_DELAYS_MS = [2000, 8000, 30000];
23
26
  export class PiAgentBackend {
24
27
  name;
25
28
  runtime;
26
29
  renderer;
27
30
  control;
31
+ retryDelaysMs;
32
+ sleep;
28
33
  constructor(options = {}) {
29
34
  this.name = options.name ?? "pi";
30
35
  this.runtime = options.models ?? builtinModels();
31
36
  this.renderer = options.renderer ?? new Renderer();
32
37
  this.control = options.control;
38
+ this.retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
39
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
33
40
  }
34
41
  /**
35
42
  * Take the host's window control. A backend that runs windows in process
@@ -89,6 +96,11 @@ export class PiAgentBackend {
89
96
  });
90
97
  }
91
98
  const results = new Map();
99
+ // The tools the window declares, built once here: the backend owns the call
100
+ // path, so it is the one that wires a spec to the host's catalog. A refresh
101
+ // while the window runs builds them again and replaces them on the agent's
102
+ // own state (docs/tools.md#external-tools-that-change).
103
+ const declareTools = (tools) => tools.map((tool) => piTool(tool, (args, callId) => this.callTool(request.window, tool.name, args, callId, results), this.renderer));
92
104
  const agent = new Agent({
93
105
  streamFn: (piModel, context, options) => this.runtime.streamSimple(piModel, context, options),
94
106
  getApiKey: async (provider) => {
@@ -98,7 +110,7 @@ export class PiAgentBackend {
98
110
  initialState: {
99
111
  systemPrompt: systemPromptOf(request),
100
112
  model,
101
- tools: request.tools.map((tool) => piTool(tool, (args, callId) => this.callTool(request.window, tool.name, args, callId, results), this.renderer)),
113
+ tools: declareTools(request.tools),
102
114
  },
103
115
  steeringMode: "all",
104
116
  followUpMode: "all",
@@ -107,7 +119,7 @@ export class PiAgentBackend {
107
119
  const first = request.firstInput ?? "";
108
120
  const run = first === ""
109
121
  ? Promise.resolve()
110
- : agent.prompt(first).then(() => undefined, () => undefined);
122
+ : this.runWithRetry(agent, first);
111
123
  return new PiWindowSession({
112
124
  agent,
113
125
  window: request.window,
@@ -115,8 +127,43 @@ export class PiAgentBackend {
115
127
  limits: { limit: request.limits.contextTokens, reserve: request.limits.reserveTokens },
116
128
  results,
117
129
  run,
130
+ refreshTools: (tools) => {
131
+ agent.state.tools = declareTools(tools);
132
+ },
118
133
  });
119
134
  }
135
+ /**
136
+ * The window's first turn, retried while the model service fails transiently.
137
+ *
138
+ * pi settles a failed run rather than throwing: the agent records an assistant
139
+ * message whose `stopReason` is `error`, and `isRetryableAssistantError` says
140
+ * whether that error is the transient kind. When it is, the failed message is
141
+ * dropped and the same request is sent again - the same bytes, in the same
142
+ * order, so the provider's cached prefix still holds and nothing the model
143
+ * already did is repeated. A run that fails any other way, or one that runs
144
+ * out of retries, rejects, which is how the session settles the window as
145
+ * failed for the host (docs/windows.md#model-failures).
146
+ */
147
+ async runWithRetry(agent, first) {
148
+ await agent.prompt(first);
149
+ for (let attempt = 0;; attempt += 1) {
150
+ const failed = failedMessageOf(agent);
151
+ if (failed === null)
152
+ return;
153
+ if (attempt >= this.retryDelaysMs.length || !isRetryableAssistantError(failed)) {
154
+ throw new Error(failed.errorMessage ?? "the model call failed");
155
+ }
156
+ const delay = this.retryDelaysMs[attempt];
157
+ if (delay !== undefined && delay > 0)
158
+ await this.sleep(delay);
159
+ // The failed turn is not an answer: dropping it leaves the transcript as
160
+ // it was, so the retry sends exactly what the first attempt sent.
161
+ agent.state.messages = agent.state.messages.filter((message) => message !== failed);
162
+ await agent.continue().catch(() => {
163
+ // The window was ended while the retry waited.
164
+ });
165
+ }
166
+ }
120
167
  /**
121
168
  * One direct tool call. The call is recorded at its event before it runs, and
122
169
  * the call itself goes through the host's tool groups - the same path a direct
@@ -164,3 +211,14 @@ function systemPromptOf(request) {
164
211
  const memory = request.prompt.memory.trim();
165
212
  return memory === "" ? request.prompt.system : `${request.prompt.system}\n\n${memory}`;
166
213
  }
214
+ /**
215
+ * The assistant message a failed run left on the transcript, or null when the
216
+ * last message is not one. pi settles a provider failure by recording it, so
217
+ * this is where a run's failure is read from.
218
+ */
219
+ function failedMessageOf(agent) {
220
+ const last = agent.state.messages.at(-1);
221
+ if (last === undefined || last.role !== "assistant")
222
+ return null;
223
+ return last.stopReason === "error" ? last : null;
224
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4fu/arbor-agent-pi",
3
- "version": "2026.1106.0",
3
+ "version": "2026.1107.0",
4
4
  "type": "module",
5
5
  "description": "The default AgentBackend: pi-agent-core in process, with its own system prompt and no pi type crossing the interface.",
6
6
  "exports": {
@@ -42,9 +42,9 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@4fu/arbor-errors": "2026.1106.0",
46
- "@4fu/arbor-interfaces": "2026.1106.0",
47
- "@4fu/arbor-prompt": "2026.1106.0",
45
+ "@4fu/arbor-errors": "2026.1107.0",
46
+ "@4fu/arbor-interfaces": "2026.1107.0",
47
+ "@4fu/arbor-prompt": "2026.1107.0",
48
48
  "@earendil-works/pi-agent-core": "1.5.0",
49
49
  "@earendil-works/pi-ai": "1.5.0"
50
50
  }
package/session.d.ts CHANGED
@@ -5,9 +5,15 @@
5
5
  * boundary through pi's steering, queues follow-ups for after the run, ends the
6
6
  * run, and reads tool calls, results and usage as they happen. Nothing pi owns
7
7
  * crosses the interface.
8
+ *
9
+ * What the model is writing while it runs travels the same way: pi's
10
+ * `message_update` carries its own text and tool-call argument deltas, and this
11
+ * session reports them as the core's `StreamEvent`, so a host surface can show a
12
+ * window's output - above all what it is about to `send` - before the call is
13
+ * even complete (docs/windows.md#streaming).
8
14
  */
9
15
  import type { Agent } from "@earendil-works/pi-agent-core";
10
- import type { Delivery, Handoff, TokenUsage, ToolResult, WindowEndReason, WindowEvent, WindowOutcome, WindowRef, WindowSession, WindowState } from "@4fu/arbor-interfaces";
16
+ import type { Delivery, DirectToolSpec, Handoff, TokenUsage, ToolResult, WindowEndReason, WindowEvent, WindowOutcome, WindowRef, WindowSession, WindowState } from "@4fu/arbor-interfaces";
11
17
  import type { Renderer } from "@4fu/arbor-prompt";
12
18
  export interface PiSessionOptions {
13
19
  agent: Agent;
@@ -21,6 +27,12 @@ export interface PiSessionOptions {
21
27
  results?: Map<string, ToolResult>;
22
28
  /** The first turn, already started by the backend. */
23
29
  run: Promise<void>;
30
+ /**
31
+ * Replace the tools this window declares, when the host's set changed while
32
+ * the window runs. The backend owns the call path, so it builds them
33
+ * (docs/tools.md#external-tools-that-change).
34
+ */
35
+ refreshTools?: (tools: readonly DirectToolSpec[]) => void;
24
36
  }
25
37
  export declare class PiWindowSession implements WindowSession {
26
38
  readonly window: WindowRef;
@@ -34,10 +46,30 @@ export declare class PiWindowSession implements WindowSession {
34
46
  private paused;
35
47
  private current;
36
48
  private readonly results;
49
+ /**
50
+ * The arguments of the tool call being written, by content index. pi reports
51
+ * them as JSON text in pieces, and a call still being written is not valid
52
+ * JSON yet, so they are read with pi's own tolerant parser.
53
+ */
54
+ private readonly writing;
55
+ /**
56
+ * How this window's declared tools are replaced when the host's set changes.
57
+ * The backend owns the call path, so it builds them
58
+ * (docs/tools.md#external-tools-that-change).
59
+ */
60
+ private readonly refresh;
37
61
  constructor(options: PiSessionOptions);
38
62
  get state(): WindowState;
39
63
  steer(delivery: Delivery): Promise<void>;
40
64
  followUp(delivery: Delivery): Promise<void>;
65
+ /**
66
+ * The tools this window may call changed. pi keeps its declaration on the
67
+ * agent's own state, which may be replaced while the window runs, so a tool
68
+ * that appeared is callable from the next model request and one that was
69
+ * removed is not - and the window's prefix is never rebuilt for it
70
+ * (docs/tools.md#external-tools-that-change).
71
+ */
72
+ refreshTools(tools: readonly DirectToolSpec[]): void;
41
73
  end(handoff?: Handoff, reason?: WindowEndReason): Promise<void>;
42
74
  pause(): Promise<void>;
43
75
  resume(): Promise<void>;
@@ -48,6 +80,15 @@ export declare class PiWindowSession implements WindowSession {
48
80
  /** Continue an idle window, so a queued delivery is picked up. */
49
81
  private continue_;
50
82
  private onAgentEvent;
83
+ /**
84
+ * One delta of what the model is writing, or nothing when this event is not
85
+ * one. pi's `message_update` carries the provider's own event: `text_delta`
86
+ * for assistant text, `toolcall_delta` for the arguments of a tool call as
87
+ * they are written. What stands so far is what the delta reports, so a
88
+ * surface that shows a window's output never reassembles the pieces itself
89
+ * (docs/windows.md#streaming).
90
+ */
91
+ private deltaOf;
51
92
  /**
52
93
  * The window's footprint: the last request's input, cache read and cache
53
94
  * write. A running total would resend the context on every turn and cross
package/session.js CHANGED
@@ -5,7 +5,14 @@
5
5
  * boundary through pi's steering, queues follow-ups for after the run, ends the
6
6
  * run, and reads tool calls, results and usage as they happen. Nothing pi owns
7
7
  * crosses the interface.
8
+ *
9
+ * What the model is writing while it runs travels the same way: pi's
10
+ * `message_update` carries its own text and tool-call argument deltas, and this
11
+ * session reports them as the core's `StreamEvent`, so a host surface can show a
12
+ * window's output - above all what it is about to `send` - before the call is
13
+ * even complete (docs/windows.md#streaming).
8
14
  */
15
+ import { ToolCallArgumentsStream } from "@earendil-works/pi-ai";
9
16
  import { arborError } from "@4fu/arbor-errors";
10
17
  import { renderDelivery } from "@4fu/arbor-prompt";
11
18
  /** A queue of window events the host drains as they happen. */
@@ -51,9 +58,22 @@ export class PiWindowSession {
51
58
  paused = false;
52
59
  current;
53
60
  results;
61
+ /**
62
+ * The arguments of the tool call being written, by content index. pi reports
63
+ * them as JSON text in pieces, and a call still being written is not valid
64
+ * JSON yet, so they are read with pi's own tolerant parser.
65
+ */
66
+ writing = new Map();
67
+ /**
68
+ * How this window's declared tools are replaced when the host's set changes.
69
+ * The backend owns the call path, so it builds them
70
+ * (docs/tools.md#external-tools-that-change).
71
+ */
72
+ refresh;
54
73
  constructor(options) {
55
74
  this.window = options.window;
56
75
  this.agent = options.agent;
76
+ this.refresh = options.refreshTools;
57
77
  this.renderer = options.renderer;
58
78
  this.results = options.results ?? new Map();
59
79
  this.current = { used: 0, limit: options.limits.limit, reserve: options.limits.reserve };
@@ -91,6 +111,18 @@ export class PiWindowSession {
91
111
  return;
92
112
  this.enqueue(delivery, "followUp");
93
113
  }
114
+ /**
115
+ * The tools this window may call changed. pi keeps its declaration on the
116
+ * agent's own state, which may be replaced while the window runs, so a tool
117
+ * that appeared is callable from the next model request and one that was
118
+ * removed is not - and the window's prefix is never rebuilt for it
119
+ * (docs/tools.md#external-tools-that-change).
120
+ */
121
+ refreshTools(tools) {
122
+ if (this.ended)
123
+ return;
124
+ this.refresh?.(tools);
125
+ }
94
126
  async end(handoff, reason = "model_ended") {
95
127
  if (this.ended)
96
128
  return;
@@ -131,6 +163,16 @@ export class PiWindowSession {
131
163
  }
132
164
  onAgentEvent(event) {
133
165
  switch (event.type) {
166
+ case "message_start":
167
+ // A new response numbers its content blocks from zero again.
168
+ this.writing.clear();
169
+ break;
170
+ case "message_update": {
171
+ const delta = this.deltaOf(event);
172
+ if (delta !== undefined)
173
+ this.stream.push(delta);
174
+ break;
175
+ }
134
176
  case "tool_execution_start":
135
177
  this.stream.push({
136
178
  kind: "tool_call",
@@ -145,8 +187,16 @@ export class PiWindowSession {
145
187
  });
146
188
  break;
147
189
  case "message_end":
148
- if (event.message.role === "assistant")
190
+ if (event.message.role === "assistant") {
149
191
  this.trackUsage(event.message.usage);
192
+ // What the model said in this turn, as one event on the window's
193
+ // record: the host journals it, so `windows.show` replays it and a
194
+ // continuation reads what its predecessor said. A message that only
195
+ // carries tool calls says nothing and records nothing.
196
+ const text = assistantText(event.message);
197
+ if (text !== "")
198
+ this.stream.push({ kind: "appended", event: text });
199
+ }
150
200
  break;
151
201
  case "agent_end":
152
202
  this.stream.push({ kind: "run_ended", outcome: this.outcomeOf("model_ended") });
@@ -155,6 +205,35 @@ export class PiWindowSession {
155
205
  break;
156
206
  }
157
207
  }
208
+ /**
209
+ * One delta of what the model is writing, or nothing when this event is not
210
+ * one. pi's `message_update` carries the provider's own event: `text_delta`
211
+ * for assistant text, `toolcall_delta` for the arguments of a tool call as
212
+ * they are written. What stands so far is what the delta reports, so a
213
+ * surface that shows a window's output never reassembles the pieces itself
214
+ * (docs/windows.md#streaming).
215
+ */
216
+ deltaOf(event) {
217
+ const inner = event.assistantMessageEvent;
218
+ if (inner.type === "text_delta")
219
+ return { kind: "text_delta", delta: inner.delta };
220
+ // A provider reports the tool call's name and id on the chunk that opens it,
221
+ // which need not carry any argument text yet.
222
+ if (inner.type !== "toolcall_delta" || inner.delta === "")
223
+ return undefined;
224
+ const block = writingCall(event.message, inner.contentIndex);
225
+ if (block === undefined)
226
+ return undefined;
227
+ let arguments_ = this.writing.get(inner.contentIndex);
228
+ if (arguments_ === undefined) {
229
+ arguments_ = new ToolCallArgumentsStream();
230
+ this.writing.set(inner.contentIndex, arguments_);
231
+ }
232
+ return {
233
+ kind: "tool_call_delta",
234
+ call: { name: block.name, args: arguments_.append(inner.delta), callId: block.id },
235
+ };
236
+ }
158
237
  /**
159
238
  * The window's footprint: the last request's input, cache read and cache
160
239
  * write. A running total would resend the context on every turn and cross
@@ -215,3 +294,20 @@ export class PiWindowSession {
215
294
  function callOf(name, args, callId) {
216
295
  return { name, args: (args ?? {}), callId };
217
296
  }
297
+ /** The text one assistant message carries, without its tool calls. */
298
+ function assistantText(message) {
299
+ return message.content
300
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
301
+ .join("\n");
302
+ }
303
+ /**
304
+ * The tool call block at one content index of a message still being written.
305
+ * pi keeps the partial message's blocks in place, so the block a delta belongs
306
+ * to is the one at its own index; its name and id may still be filling in.
307
+ */
308
+ function writingCall(message, index) {
309
+ if (message.role !== "assistant")
310
+ return undefined;
311
+ const block = message.content[index];
312
+ return block !== undefined && block.type === "toolCall" ? block : undefined;
313
+ }
package/tools.d.ts CHANGED
@@ -10,15 +10,27 @@
10
10
  * error block - the rule and the next action, one line.
11
11
  */
12
12
  import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
13
- import type { ParameterSpec, ToolResult } from "@4fu/arbor-interfaces";
13
+ import type { JSONSchema, ParameterSpec, ToolResult } from "@4fu/arbor-interfaces";
14
14
  import type { Renderer } from "@4fu/arbor-prompt";
15
15
  /** One parameter's JSON Schema, from its `ParameterSpec`. */
16
16
  export declare function parameterSchema(parameter: ParameterSpec): Record<string, unknown>;
17
- /** The JSON Schema one tool declares to pi. */
18
- export declare function toolParameters(parameters: Record<string, ParameterSpec>): Record<string, unknown>;
19
17
  /**
20
- * What the model reads of one result. A refusal is rendered as the error block,
21
- * so the model always gets the rule and the next action, never an empty result.
18
+ * The JSON Schema one tool declares to pi.
19
+ *
20
+ * A tool declared from outside the core hands over the JSON Schema its own
21
+ * harness declared, and that is what pi sends: `enum`, `items`, `oneOf`, a
22
+ * nested object's own properties, all of it, because the model calls the tool
23
+ * with the schema it was given and a summary of it would only drop what it says
24
+ * (docs/tools.md#external-tools). A tool that declares none - arbor's own - is
25
+ * built from its `ParameterSpec`s, which is the declaration for those.
26
+ */
27
+ export declare function toolParameters(parameters: Record<string, ParameterSpec>, schema?: JSONSchema): Record<string, unknown>;
28
+ /**
29
+ * What the model reads of one result. A successful result is rendered as the
30
+ * `tool_result` block, so it is escaped and marked as data like every other
31
+ * block. A refusal is rendered as the error block, so the model always gets
32
+ * the rule and the next action, never an empty result; a failure that carries
33
+ * no error shape is marked as a failed result instead.
22
34
  */
23
35
  export declare function resultText(result: ToolResult, renderer: Renderer): string;
24
36
  /** The core's result as pi's. */
@@ -28,4 +40,5 @@ export declare function piTool(spec: {
28
40
  name: string;
29
41
  description: string;
30
42
  parameters: Record<string, ParameterSpec>;
43
+ schema?: JSONSchema;
31
44
  }, call: (args: Record<string, unknown>, callId: string) => Promise<ToolResult>, renderer: Renderer): AgentTool<Record<string, unknown>>;
package/tools.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * core's own `ToolResult`, and a refusal reaches the model as the renderer's
10
10
  * error block - the rule and the next action, one line.
11
11
  */
12
- import { errorBlock } from "@4fu/arbor-prompt";
12
+ import { errorBlock, toolResultBlock } from "@4fu/arbor-prompt";
13
13
  /** One parameter's JSON Schema, from its `ParameterSpec`. */
14
14
  export function parameterSchema(parameter) {
15
15
  const schema = {
@@ -44,8 +44,19 @@ function jsonType(type) {
44
44
  return "string";
45
45
  }
46
46
  }
47
- /** The JSON Schema one tool declares to pi. */
48
- export function toolParameters(parameters) {
47
+ /**
48
+ * The JSON Schema one tool declares to pi.
49
+ *
50
+ * A tool declared from outside the core hands over the JSON Schema its own
51
+ * harness declared, and that is what pi sends: `enum`, `items`, `oneOf`, a
52
+ * nested object's own properties, all of it, because the model calls the tool
53
+ * with the schema it was given and a summary of it would only drop what it says
54
+ * (docs/tools.md#external-tools). A tool that declares none - arbor's own - is
55
+ * built from its `ParameterSpec`s, which is the declaration for those.
56
+ */
57
+ export function toolParameters(parameters, schema) {
58
+ if (schema !== undefined)
59
+ return schema;
49
60
  const properties = {};
50
61
  const required = [];
51
62
  for (const [name, parameter] of Object.entries(parameters)) {
@@ -56,23 +67,28 @@ export function toolParameters(parameters) {
56
67
  return { type: "object", properties, ...(required.length === 0 ? {} : { required }) };
57
68
  }
58
69
  /**
59
- * What the model reads of one result. A refusal is rendered as the error block,
60
- * so the model always gets the rule and the next action, never an empty result.
70
+ * What the model reads of one result. A successful result is rendered as the
71
+ * `tool_result` block, so it is escaped and marked as data like every other
72
+ * block. A refusal is rendered as the error block, so the model always gets
73
+ * the rule and the next action, never an empty result; a failure that carries
74
+ * no error shape is marked as a failed result instead.
61
75
  */
62
76
  export function resultText(result, renderer) {
63
- if (result.ok)
64
- return result.content;
65
- const error = result.error;
66
- if (error === undefined)
67
- return result.content;
77
+ const name = result.tool ?? result.callId;
78
+ const error = result.ok ? undefined : result.error;
79
+ if (error !== undefined) {
80
+ return renderer.render([
81
+ errorBlock({
82
+ tool: name,
83
+ rule: error.rule,
84
+ message: error.message,
85
+ ...(error.nextAction === undefined ? {} : { nextAction: error.nextAction }),
86
+ ...(error.refs === undefined ? {} : { refs: error.refs }),
87
+ }),
88
+ ]);
89
+ }
68
90
  return renderer.render([
69
- errorBlock({
70
- tool: result.callId,
71
- rule: error.rule,
72
- message: error.message,
73
- ...(error.nextAction === undefined ? {} : { nextAction: error.nextAction }),
74
- ...(error.refs === undefined ? {} : { refs: error.refs }),
75
- }),
91
+ toolResultBlock({ callId: result.callId, name }, result.content, result.ok ? "ok" : "failed"),
76
92
  ]);
77
93
  }
78
94
  /** The core's result as pi's. */
@@ -89,7 +105,7 @@ export function piTool(spec, call, renderer) {
89
105
  name: spec.name,
90
106
  label: spec.name,
91
107
  description: spec.description,
92
- parameters: toolParameters(spec.parameters),
108
+ parameters: toolParameters(spec.parameters, spec.schema),
93
109
  execute: async (callId, params) => {
94
110
  const result = await call((params ?? {}), callId);
95
111
  return toolResultOf(result, renderer);