@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 +14 -6
- package/backend.d.ts +26 -0
- package/backend.js +60 -2
- package/package.json +4 -4
- package/session.d.ts +42 -1
- package/session.js +97 -1
- package/tools.d.ts +18 -5
- package/tools.js +34 -18
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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.
|
|
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.
|
|
46
|
-
"@4fu/arbor-interfaces": "2026.
|
|
47
|
-
"@4fu/arbor-prompt": "2026.
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
/**
|
|
48
|
-
|
|
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
|
|
60
|
-
*
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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);
|