@4fu/arbor-agent-pi 0.1.0 → 2026.1106.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
@@ -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.
@@ -118,7 +120,8 @@ window gets, and how a continuation keeps working in it, are in
118
120
  | [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
119
121
  | [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
120
122
  | [`@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 |
123
+ | [`@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 |
124
+ | [`@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
125
  | [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
123
126
  | [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
124
127
  | [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
@@ -151,7 +154,7 @@ deployment can see what it would be taking on before it adopts one.
151
154
  | [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
152
155
  | [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
153
156
  | [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 |
157
+ | [docs/workspaces.md](docs/workspaces.md) | the shared folder and per-window checkouts: isolation, continuity, what the agent does |
155
158
  | [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
156
159
  | [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
157
160
  | [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
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
@@ -107,7 +114,7 @@ export class PiAgentBackend {
107
114
  const first = request.firstInput ?? "";
108
115
  const run = first === ""
109
116
  ? Promise.resolve()
110
- : agent.prompt(first).then(() => undefined, () => undefined);
117
+ : this.runWithRetry(agent, first);
111
118
  return new PiWindowSession({
112
119
  agent,
113
120
  window: request.window,
@@ -117,6 +124,38 @@ export class PiAgentBackend {
117
124
  run,
118
125
  });
119
126
  }
127
+ /**
128
+ * The window's first turn, retried while the model service fails transiently.
129
+ *
130
+ * pi settles a failed run rather than throwing: the agent records an assistant
131
+ * message whose `stopReason` is `error`, and `isRetryableAssistantError` says
132
+ * whether that error is the transient kind. When it is, the failed message is
133
+ * dropped and the same request is sent again - the same bytes, in the same
134
+ * order, so the provider's cached prefix still holds and nothing the model
135
+ * already did is repeated. A run that fails any other way, or one that runs
136
+ * out of retries, rejects, which is how the session settles the window as
137
+ * failed for the host (docs/windows.md#model-failures).
138
+ */
139
+ async runWithRetry(agent, first) {
140
+ await agent.prompt(first);
141
+ for (let attempt = 0;; attempt += 1) {
142
+ const failed = failedMessageOf(agent);
143
+ if (failed === null)
144
+ return;
145
+ if (attempt >= this.retryDelaysMs.length || !isRetryableAssistantError(failed)) {
146
+ throw new Error(failed.errorMessage ?? "the model call failed");
147
+ }
148
+ const delay = this.retryDelaysMs[attempt];
149
+ if (delay !== undefined && delay > 0)
150
+ await this.sleep(delay);
151
+ // The failed turn is not an answer: dropping it leaves the transcript as
152
+ // it was, so the retry sends exactly what the first attempt sent.
153
+ agent.state.messages = agent.state.messages.filter((message) => message !== failed);
154
+ await agent.continue().catch(() => {
155
+ // The window was ended while the retry waited.
156
+ });
157
+ }
158
+ }
120
159
  /**
121
160
  * One direct tool call. The call is recorded at its event before it runs, and
122
161
  * the call itself goes through the host's tool groups - the same path a direct
@@ -164,3 +203,14 @@ function systemPromptOf(request) {
164
203
  const memory = request.prompt.memory.trim();
165
204
  return memory === "" ? request.prompt.system : `${request.prompt.system}\n\n${memory}`;
166
205
  }
206
+ /**
207
+ * The assistant message a failed run left on the transcript, or null when the
208
+ * last message is not one. pi settles a provider failure by recording it, so
209
+ * this is where a run's failure is read from.
210
+ */
211
+ function failedMessageOf(agent) {
212
+ const last = agent.state.messages.at(-1);
213
+ if (last === undefined || last.role !== "assistant")
214
+ return null;
215
+ return last.stopReason === "error" ? last : null;
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4fu/arbor-agent-pi",
3
- "version": "0.1.0",
3
+ "version": "2026.1106.1",
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": "0.1.0",
46
- "@4fu/arbor-interfaces": "0.1.0",
47
- "@4fu/arbor-prompt": "0.1.0",
45
+ "@4fu/arbor-errors": "2026.1106.1",
46
+ "@4fu/arbor-interfaces": "2026.1106.1",
47
+ "@4fu/arbor-prompt": "2026.1106.1",
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,6 +5,12 @@
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
16
  import type { Delivery, Handoff, TokenUsage, ToolResult, WindowEndReason, WindowEvent, WindowOutcome, WindowRef, WindowSession, WindowState } from "@4fu/arbor-interfaces";
@@ -34,6 +40,12 @@ export declare class PiWindowSession implements WindowSession {
34
40
  private paused;
35
41
  private current;
36
42
  private readonly results;
43
+ /**
44
+ * The arguments of the tool call being written, by content index. pi reports
45
+ * them as JSON text in pieces, and a call still being written is not valid
46
+ * JSON yet, so they are read with pi's own tolerant parser.
47
+ */
48
+ private readonly writing;
37
49
  constructor(options: PiSessionOptions);
38
50
  get state(): WindowState;
39
51
  steer(delivery: Delivery): Promise<void>;
@@ -48,6 +60,15 @@ export declare class PiWindowSession implements WindowSession {
48
60
  /** Continue an idle window, so a queued delivery is picked up. */
49
61
  private continue_;
50
62
  private onAgentEvent;
63
+ /**
64
+ * One delta of what the model is writing, or nothing when this event is not
65
+ * one. pi's `message_update` carries the provider's own event: `text_delta`
66
+ * for assistant text, `toolcall_delta` for the arguments of a tool call as
67
+ * they are written. What stands so far is what the delta reports, so a
68
+ * surface that shows a window's output never reassembles the pieces itself
69
+ * (docs/windows.md#streaming).
70
+ */
71
+ private deltaOf;
51
72
  /**
52
73
  * The window's footprint: the last request's input, cache read and cache
53
74
  * 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,6 +58,12 @@ 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();
54
67
  constructor(options) {
55
68
  this.window = options.window;
56
69
  this.agent = options.agent;
@@ -131,6 +144,16 @@ export class PiWindowSession {
131
144
  }
132
145
  onAgentEvent(event) {
133
146
  switch (event.type) {
147
+ case "message_start":
148
+ // A new response numbers its content blocks from zero again.
149
+ this.writing.clear();
150
+ break;
151
+ case "message_update": {
152
+ const delta = this.deltaOf(event);
153
+ if (delta !== undefined)
154
+ this.stream.push(delta);
155
+ break;
156
+ }
134
157
  case "tool_execution_start":
135
158
  this.stream.push({
136
159
  kind: "tool_call",
@@ -145,8 +168,16 @@ export class PiWindowSession {
145
168
  });
146
169
  break;
147
170
  case "message_end":
148
- if (event.message.role === "assistant")
171
+ if (event.message.role === "assistant") {
149
172
  this.trackUsage(event.message.usage);
173
+ // What the model said in this turn, as one event on the window's
174
+ // record: the host journals it, so `windows.show` replays it and a
175
+ // continuation reads what its predecessor said. A message that only
176
+ // carries tool calls says nothing and records nothing.
177
+ const text = assistantText(event.message);
178
+ if (text !== "")
179
+ this.stream.push({ kind: "appended", event: text });
180
+ }
150
181
  break;
151
182
  case "agent_end":
152
183
  this.stream.push({ kind: "run_ended", outcome: this.outcomeOf("model_ended") });
@@ -155,6 +186,35 @@ export class PiWindowSession {
155
186
  break;
156
187
  }
157
188
  }
189
+ /**
190
+ * One delta of what the model is writing, or nothing when this event is not
191
+ * one. pi's `message_update` carries the provider's own event: `text_delta`
192
+ * for assistant text, `toolcall_delta` for the arguments of a tool call as
193
+ * they are written. What stands so far is what the delta reports, so a
194
+ * surface that shows a window's output never reassembles the pieces itself
195
+ * (docs/windows.md#streaming).
196
+ */
197
+ deltaOf(event) {
198
+ const inner = event.assistantMessageEvent;
199
+ if (inner.type === "text_delta")
200
+ return { kind: "text_delta", delta: inner.delta };
201
+ // A provider reports the tool call's name and id on the chunk that opens it,
202
+ // which need not carry any argument text yet.
203
+ if (inner.type !== "toolcall_delta" || inner.delta === "")
204
+ return undefined;
205
+ const block = writingCall(event.message, inner.contentIndex);
206
+ if (block === undefined)
207
+ return undefined;
208
+ let arguments_ = this.writing.get(inner.contentIndex);
209
+ if (arguments_ === undefined) {
210
+ arguments_ = new ToolCallArgumentsStream();
211
+ this.writing.set(inner.contentIndex, arguments_);
212
+ }
213
+ return {
214
+ kind: "tool_call_delta",
215
+ call: { name: block.name, args: arguments_.append(inner.delta), callId: block.id },
216
+ };
217
+ }
158
218
  /**
159
219
  * The window's footprint: the last request's input, cache read and cache
160
220
  * write. A running total would resend the context on every turn and cross
@@ -215,3 +275,20 @@ export class PiWindowSession {
215
275
  function callOf(name, args, callId) {
216
276
  return { name, args: (args ?? {}), callId };
217
277
  }
278
+ /** The text one assistant message carries, without its tool calls. */
279
+ function assistantText(message) {
280
+ return message.content
281
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
282
+ .join("\n");
283
+ }
284
+ /**
285
+ * The tool call block at one content index of a message still being written.
286
+ * pi keeps the partial message's blocks in place, so the block a delta belongs
287
+ * to is the one at its own index; its name and id may still be filling in.
288
+ */
289
+ function writingCall(message, index) {
290
+ if (message.role !== "assistant")
291
+ return undefined;
292
+ const block = message.content[index];
293
+ return block !== undefined && block.type === "toolCall" ? block : undefined;
294
+ }
package/tools.d.ts CHANGED
@@ -17,8 +17,11 @@ export declare function parameterSchema(parameter: ParameterSpec): Record<string
17
17
  /** The JSON Schema one tool declares to pi. */
18
18
  export declare function toolParameters(parameters: Record<string, ParameterSpec>): Record<string, unknown>;
19
19
  /**
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.
20
+ * What the model reads of one result. A successful result is rendered as the
21
+ * `tool_result` block, so it is escaped and marked as data like every other
22
+ * block. A refusal is rendered as the error block, so the model always gets
23
+ * the rule and the next action, never an empty result; a failure that carries
24
+ * no error shape is marked as a failed result instead.
22
25
  */
23
26
  export declare function resultText(result: ToolResult, renderer: Renderer): string;
24
27
  /** The core's result as pi's. */
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 = {
@@ -56,23 +56,28 @@ export function toolParameters(parameters) {
56
56
  return { type: "object", properties, ...(required.length === 0 ? {} : { required }) };
57
57
  }
58
58
  /**
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.
59
+ * What the model reads of one result. A successful result is rendered as the
60
+ * `tool_result` block, so it is escaped and marked as data like every other
61
+ * block. A refusal is rendered as the error block, so the model always gets
62
+ * the rule and the next action, never an empty result; a failure that carries
63
+ * no error shape is marked as a failed result instead.
61
64
  */
62
65
  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;
66
+ const name = result.tool ?? result.callId;
67
+ const error = result.ok ? undefined : result.error;
68
+ if (error !== undefined) {
69
+ return renderer.render([
70
+ errorBlock({
71
+ tool: name,
72
+ rule: error.rule,
73
+ message: error.message,
74
+ ...(error.nextAction === undefined ? {} : { nextAction: error.nextAction }),
75
+ ...(error.refs === undefined ? {} : { refs: error.refs }),
76
+ }),
77
+ ]);
78
+ }
68
79
  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
- }),
80
+ toolResultBlock({ callId: result.callId, name }, result.content, result.ok ? "ok" : "failed"),
76
81
  ]);
77
82
  }
78
83
  /** The core's result as pi's. */