@4fu/arbor-agent-pi 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +187 -2
- package/backend.d.ts +69 -0
- package/backend.js +166 -0
- package/index.d.ts +12 -0
- package/index.js +12 -0
- package/package.json +49 -4
- package/session.d.ts +62 -0
- package/session.js +217 -0
- package/testing.d.ts +8 -0
- package/testing.js +8 -0
- package/tools.d.ts +31 -0
- package/tools.js +98 -0
- package/usage.d.ts +18 -0
- package/usage.js +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 4fuu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,188 @@
|
|
|
1
|
-
#
|
|
1
|
+
# arbor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
arbor is a framework for an agent without sessions: many context windows run
|
|
4
|
+
at once over one shared record, so work moves in parallel, replies come fast,
|
|
5
|
+
and every window can find everything that was ever done.
|
|
6
|
+
|
|
7
|
+
A deployment composes the pieces it wants and registers plugins for the domains
|
|
8
|
+
it serves. The framework guarantees what an agent cannot guarantee for itself -
|
|
9
|
+
the record, isolation, exactly-once effects, the checks, dispatch - and leaves
|
|
10
|
+
everything that takes judgment, such as git, merges and conflicts, to the agent.
|
|
11
|
+
|
|
12
|
+
## Core ideas
|
|
13
|
+
|
|
14
|
+
- **No sessions to manage.** Work runs in disposable context windows. Any
|
|
15
|
+
number run at the same time; none of them owns anything, and a window that
|
|
16
|
+
ends loses nothing. The model looks up what it needs instead of carrying it.
|
|
17
|
+
- **One record, shared live.** Every input, tool call, result, send and note
|
|
18
|
+
revision goes into one journal and is visible to every window as
|
|
19
|
+
soon as it is written. Records never merge and are never lost.
|
|
20
|
+
- **Dispatch by a judge.** A judge (Jev by default, replaceable) decides which
|
|
21
|
+
live window takes a new input, or opens a new one. There is no router session
|
|
22
|
+
and no main line.
|
|
23
|
+
- **Current without locks.** Windows run in parallel. New events on a
|
|
24
|
+
conversation a window is in are steered into it as they arrive; only a note
|
|
25
|
+
write is checked against the revision the window read.
|
|
26
|
+
- **Mechanisms in the framework, policy in the agent.** The framework
|
|
27
|
+
guarantees what an agent cannot guarantee for itself - the record, isolation,
|
|
28
|
+
exactly-once effects, the checks, dispatch. Everything that takes judgment,
|
|
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.
|
|
33
|
+
- **A self-changing environment.** The model adds and changes skills, MCP
|
|
34
|
+
servers and resident services itself; a `reload` validates the change and
|
|
35
|
+
broadcasts it to every live window as a delta of names and descriptions.
|
|
36
|
+
- **Minimal limits.** The framework sets no policy. Permissions, privacy
|
|
37
|
+
review, budgets and approvals belong to deployments built on it, through
|
|
38
|
+
hooks.
|
|
39
|
+
- **Direct tools plus code mode.** Decisions are direct tools; everything else
|
|
40
|
+
is a module called from `code_exec`. There is no CLI.
|
|
41
|
+
- **Every module is replaceable.** The agent backend (pi by default), the
|
|
42
|
+
journal, notes, workspaces, the judge, channels and the rest are interfaces
|
|
43
|
+
([docs/architecture.md](docs/architecture.md#interfaces)).
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
Node 24 or newer. Every package is published separately, so a deployment
|
|
48
|
+
installs the core and the defaults it wants:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npm install @4fu/arbor-core @4fu/arbor-journal-sqlite @4fu/arbor-notes-sqlite \
|
|
52
|
+
@4fu/arbor-environment-dir @4fu/arbor-supervisor-process @4fu/arbor-agent-pi \
|
|
53
|
+
@4fu/arbor-workspace-dir
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`@4fu/arbor-plugin-http` is optional; it is the example plugin below.
|
|
57
|
+
`@4fu/arbor-segmenter-jieba` is an optional peer of the journal.
|
|
58
|
+
|
|
59
|
+
## A minimal host
|
|
60
|
+
|
|
61
|
+
The smallest host needs a journal and a backend. Adding the defaults and one
|
|
62
|
+
plugin is what a deployment usually starts from:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
import { mkdtemp } from "node:fs/promises";
|
|
66
|
+
import { tmpdir } from "node:os";
|
|
67
|
+
import { join } from "node:path";
|
|
68
|
+
import { Arbor } from "@4fu/arbor-core";
|
|
69
|
+
import { DirectoryEnvironment } from "@4fu/arbor-environment-dir";
|
|
70
|
+
import { SqliteJournal } from "@4fu/arbor-journal-sqlite";
|
|
71
|
+
import { SqliteNoteStore } from "@4fu/arbor-notes-sqlite";
|
|
72
|
+
import { ProcessSupervisor } from "@4fu/arbor-supervisor-process";
|
|
73
|
+
import { DirectoryWorkspace } from "@4fu/arbor-workspace-dir";
|
|
74
|
+
import { PiAgentBackend } from "@4fu/arbor-agent-pi";
|
|
75
|
+
import { HttpChannel, httpPlugin } from "@4fu/arbor-plugin-http";
|
|
76
|
+
|
|
77
|
+
const root = await mkdtemp(join(tmpdir(), "arbor-"));
|
|
78
|
+
|
|
79
|
+
// Every module below is a default implementation of an interface. Replace any
|
|
80
|
+
// one by passing your own; the host does not change.
|
|
81
|
+
const journal = new SqliteJournal({ path: join(root, "journal.sqlite") });
|
|
82
|
+
const notes = new SqliteNoteStore({ path: join(root, "notes.sqlite"), journal });
|
|
83
|
+
const supervisor = new ProcessSupervisor({ root: join(root, "services") });
|
|
84
|
+
const environment = new DirectoryEnvironment({ root: join(root, "env"), supervisor });
|
|
85
|
+
const workspace = new DirectoryWorkspace({ root: join(root, "workspaces") });
|
|
86
|
+
|
|
87
|
+
// A plugin adds; it never patches the core. This one is a chat channel: a
|
|
88
|
+
// webhook in, a window's sends out, and a page that drives the whole host.
|
|
89
|
+
let arbor;
|
|
90
|
+
const channel = new HttpChannel({ host: "127.0.0.1", port: 8787, board: () => arbor.boardLines() });
|
|
91
|
+
arbor = new Arbor({
|
|
92
|
+
journal,
|
|
93
|
+
notes,
|
|
94
|
+
backend: new PiAgentBackend(),
|
|
95
|
+
workspace,
|
|
96
|
+
environment,
|
|
97
|
+
supervisor,
|
|
98
|
+
plugins: [httpPlugin({ channel })],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await arbor.start();
|
|
102
|
+
await channel.listen();
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`arbor.open(task)` dispatches one task, and the journal is where everything that
|
|
106
|
+
happened can be read back ([docs/journal.md](docs/journal.md)). The workspace a
|
|
107
|
+
window gets, and how a continuation keeps working in it, are in
|
|
108
|
+
[docs/workspaces.md](docs/workspaces.md).
|
|
109
|
+
|
|
110
|
+
## Packages
|
|
111
|
+
|
|
112
|
+
| Package | What it is |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| [`@4fu/arbor-interfaces`](packages/interfaces) | every replaceable interface, plus the core's own shapes. Types only. |
|
|
115
|
+
| [`@4fu/arbor-errors`](packages/errors) | the error shape every refusal uses: the rule and the next action |
|
|
116
|
+
| [`@4fu/arbor-prompt`](packages/prompt) | typed blocks, the one deterministic renderer and the replaceable text table |
|
|
117
|
+
| [`@4fu/arbor-tools`](packages/tools) | the entry points: direct tools, code mode modules, the host API |
|
|
118
|
+
| [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
|
|
119
|
+
| [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
|
|
120
|
+
| [`@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 |
|
|
122
|
+
| [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
|
|
123
|
+
| [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
|
|
124
|
+
| [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
125
|
+
| [`@4fu/arbor-agent-pi`](packages/agent-pi) | the default `AgentBackend`: pi-agent-core in process |
|
|
126
|
+
| [`@4fu/arbor-plugin-http`](packages/plugin-http) | the HTTP test channel: a webhook in, sends out, a page that drives the whole host |
|
|
127
|
+
| [`@4fu/arbor-core`](packages/core) | the service layer: one implementation of every operation - the window lifecycle, dispatch, coordination, the board, notes access, workspaces, effects and schedules |
|
|
128
|
+
|
|
129
|
+
### Defaults are examples
|
|
130
|
+
|
|
131
|
+
Every default above is a reference implementation of its interface, written to
|
|
132
|
+
be read and replaced. A real deployment is expected to swap out the ones that do
|
|
133
|
+
not fit: the SQLite journal and notes, the directory workspace, the process
|
|
134
|
+
supervisor, the environment directory, the HTTP test plugin and the Jev judge
|
|
135
|
+
are all examples of what an interface can be satisfied by, not commitments.
|
|
136
|
+
What a deployment keeps is its own choice, and the interfaces are the contract
|
|
137
|
+
([docs/architecture.md](docs/architecture.md#interfaces)).
|
|
138
|
+
|
|
139
|
+
Each package's own documentation holds the limits of that default, so a
|
|
140
|
+
deployment can see what it would be taking on before it adopts one.
|
|
141
|
+
|
|
142
|
+
## Documents
|
|
143
|
+
|
|
144
|
+
| Page | Contents |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| [docs/overview.md](docs/overview.md) | goals, concepts, principles, routes considered |
|
|
147
|
+
| [docs/architecture.md](docs/architecture.md) | layers, interfaces, stack, models, plugins, hooks, data placement |
|
|
148
|
+
| [docs/journal.md](docs/journal.md) | events and their order, targets, watermarks, external ids, refs, tracing, media, redaction |
|
|
149
|
+
| [docs/windows.md](docs/windows.md) | window lifecycle, prompt layout, reminders, continuation |
|
|
150
|
+
| [docs/dispatch.md](docs/dispatch.md) | task sources, judge decisions, insertion timing, backpressure, scheduled tasks |
|
|
151
|
+
| [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
|
|
152
|
+
| [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
|
|
153
|
+
| [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 |
|
|
155
|
+
| [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
|
|
156
|
+
| [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
|
|
157
|
+
| [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
|
|
158
|
+
|
|
159
|
+
## Repository layout
|
|
160
|
+
|
|
161
|
+
`pnpm check` typechecks and tests every package on its TypeScript sources.
|
|
162
|
+
`pnpm build` emits the `dist` publishing needs, and `pnpm pack` shows what each
|
|
163
|
+
tarball would contain ([docs/release.md](docs/release.md)).
|
|
164
|
+
|
|
165
|
+
| Path | Layer |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `packages/interfaces` | the interfaces layer: every replaceable interface, plus the core's own shapes. Types only. |
|
|
168
|
+
| `packages/errors` | the error shape every refusal uses: the rule and the next action |
|
|
169
|
+
| `packages/journal-sqlite` | the default `Journal` and `BlobStore` on SQLite |
|
|
170
|
+
| `packages/segmenter-jieba` | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
|
|
171
|
+
| `packages/notes-sqlite` | the default `NoteStore` on SQLite, beside the journal |
|
|
172
|
+
| `packages/judge-jev` | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
173
|
+
| `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 |
|
|
174
|
+
| `packages/environment-dir` | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
|
|
175
|
+
| `packages/supervisor-process` | the default `Supervisor`: one host process per service, restart with backoff, logs |
|
|
176
|
+
| `packages/plugin-http` | the HTTP test channel: a webhook in, sends out, a page that drives the whole host |
|
|
177
|
+
| `packages/prompt` | typed blocks, the one deterministic renderer and the replaceable text table |
|
|
178
|
+
| `packages/agent-pi` | the default `AgentBackend`: pi-agent-core in process |
|
|
179
|
+
| `packages/tools` | the entry points: direct tools, code mode modules, the host API |
|
|
180
|
+
| `packages/core` | the service layer: one implementation of every operation - the window lifecycle, dispatch, coordination, the board, notes access, workspaces, effects, schedules and the snapshot epochs |
|
|
181
|
+
|
|
182
|
+
`mise.toml` pins the toolchain - Node and pnpm - installed under
|
|
183
|
+
`~/.local/share/mise`, and `.agents/setup` prepares a machine from it. A machine
|
|
184
|
+
without mise is expected to carry Node 24 or newer and pnpm 12 already.
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT, see [LICENSE](LICENSE).
|
package/backend.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default `AgentBackend`: pi-agent-core in process
|
|
3
|
+
* (docs/architecture.md#interfaces).
|
|
4
|
+
*
|
|
5
|
+
* arbor uses pi's `Agent` with its own system prompt: pi's default coding
|
|
6
|
+
* prompt, context files and skills never reach the model
|
|
7
|
+
* (docs/prompting.md#no-built-in-pi-prompt). Reminders and deliveries are
|
|
8
|
+
* user-role text inside inputs, never system messages in the middle of a
|
|
9
|
+
* window, because some providers fold those into the leading system prompt and
|
|
10
|
+
* that breaks the cached prefix.
|
|
11
|
+
*
|
|
12
|
+
* No pi type crosses the interface. The backend takes the core's
|
|
13
|
+
* `OpenWindowRequest`, hands back the core's `WindowSession`, and reports the
|
|
14
|
+
* core's models, usage and outcomes. Everything it needs from the host - the
|
|
15
|
+
* catalog, the renderer, the call path - arrives through its options.
|
|
16
|
+
*/
|
|
17
|
+
import type { Models } from "@earendil-works/pi-ai";
|
|
18
|
+
import type { AgentBackend, LoginRequest, LoginResult, ModelInfo, OpenWindowRequest, ToolCall, ToolResult, WindowSession } from "@4fu/arbor-interfaces";
|
|
19
|
+
import { Renderer } from "@4fu/arbor-prompt";
|
|
20
|
+
/** What the host gives the backend about the window it is running. */
|
|
21
|
+
export interface WindowControl {
|
|
22
|
+
/** Record one call at an event, before it runs. */
|
|
23
|
+
recordToolCall(window: string, call: ToolCall): Promise<string | undefined>;
|
|
24
|
+
/** Run one call through the host's tool groups, the same path a direct call takes. */
|
|
25
|
+
runToolCall(window: string, call: ToolCall, at?: string): Promise<ToolResult>;
|
|
26
|
+
/** True once the window should end after the current turn. */
|
|
27
|
+
endRequested(window: string): boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface PiBackendOptions {
|
|
30
|
+
/** pi's model runtime. Defaults to every built-in provider. */
|
|
31
|
+
models?: Models;
|
|
32
|
+
/** The one renderer, for deliveries steered into a window. */
|
|
33
|
+
renderer?: Renderer;
|
|
34
|
+
control?: WindowControl;
|
|
35
|
+
/** The name this backend reports. */
|
|
36
|
+
name?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare class PiAgentBackend implements AgentBackend {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
private readonly runtime;
|
|
41
|
+
private readonly renderer;
|
|
42
|
+
private control;
|
|
43
|
+
constructor(options?: PiBackendOptions);
|
|
44
|
+
/**
|
|
45
|
+
* Take the host's window control. A backend that runs windows in process
|
|
46
|
+
* needs it, and the host builds it after the backend exists, so the two are
|
|
47
|
+
* joined here rather than through a constructor argument.
|
|
48
|
+
*/
|
|
49
|
+
attachControl(control: WindowControl): void;
|
|
50
|
+
/** The catalog this backend can run. Only models that take images. */
|
|
51
|
+
models(): Promise<ModelInfo[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Log in to a provider. A key is never passed here: the request names a
|
|
54
|
+
* secret, and the deployment hands the value to pi outside arbor
|
|
55
|
+
* (docs/environment.md#what-the-model-can-change).
|
|
56
|
+
*/
|
|
57
|
+
login(request: LoginRequest): Promise<LoginResult>;
|
|
58
|
+
/** Open one window and start its first turn. */
|
|
59
|
+
open(request: OpenWindowRequest): Promise<WindowSession>;
|
|
60
|
+
/**
|
|
61
|
+
* One direct tool call. The call is recorded at its event before it runs, and
|
|
62
|
+
* the call itself goes through the host's tool groups - the same path a direct
|
|
63
|
+
* call takes (docs/tools.md#rules).
|
|
64
|
+
*/
|
|
65
|
+
private callTool;
|
|
66
|
+
/** The model a request names, from the catalog. */
|
|
67
|
+
private modelOf;
|
|
68
|
+
private modelsOf;
|
|
69
|
+
}
|
package/backend.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default `AgentBackend`: pi-agent-core in process
|
|
3
|
+
* (docs/architecture.md#interfaces).
|
|
4
|
+
*
|
|
5
|
+
* arbor uses pi's `Agent` with its own system prompt: pi's default coding
|
|
6
|
+
* prompt, context files and skills never reach the model
|
|
7
|
+
* (docs/prompting.md#no-built-in-pi-prompt). Reminders and deliveries are
|
|
8
|
+
* user-role text inside inputs, never system messages in the middle of a
|
|
9
|
+
* window, because some providers fold those into the leading system prompt and
|
|
10
|
+
* that breaks the cached prefix.
|
|
11
|
+
*
|
|
12
|
+
* No pi type crosses the interface. The backend takes the core's
|
|
13
|
+
* `OpenWindowRequest`, hands back the core's `WindowSession`, and reports the
|
|
14
|
+
* core's models, usage and outcomes. Everything it needs from the host - the
|
|
15
|
+
* catalog, the renderer, the call path - arrives through its options.
|
|
16
|
+
*/
|
|
17
|
+
import { Agent } from "@earendil-works/pi-agent-core";
|
|
18
|
+
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
|
|
19
|
+
import { arborError } from "@4fu/arbor-errors";
|
|
20
|
+
import { Renderer } from "@4fu/arbor-prompt";
|
|
21
|
+
import { PiWindowSession } from "./session.js";
|
|
22
|
+
import { piTool } from "./tools.js";
|
|
23
|
+
export class PiAgentBackend {
|
|
24
|
+
name;
|
|
25
|
+
runtime;
|
|
26
|
+
renderer;
|
|
27
|
+
control;
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.name = options.name ?? "pi";
|
|
30
|
+
this.runtime = options.models ?? builtinModels();
|
|
31
|
+
this.renderer = options.renderer ?? new Renderer();
|
|
32
|
+
this.control = options.control;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Take the host's window control. A backend that runs windows in process
|
|
36
|
+
* needs it, and the host builds it after the backend exists, so the two are
|
|
37
|
+
* joined here rather than through a constructor argument.
|
|
38
|
+
*/
|
|
39
|
+
attachControl(control) {
|
|
40
|
+
this.control = control;
|
|
41
|
+
}
|
|
42
|
+
/** The catalog this backend can run. Only models that take images. */
|
|
43
|
+
async models() {
|
|
44
|
+
const found = [];
|
|
45
|
+
for (const model of this.runtime.getModels()) {
|
|
46
|
+
if (!model.input.includes("image"))
|
|
47
|
+
continue;
|
|
48
|
+
found.push({ id: model.id, provider: model.provider, name: model.name, images: true });
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Log in to a provider. A key is never passed here: the request names a
|
|
54
|
+
* secret, and the deployment hands the value to pi outside arbor
|
|
55
|
+
* (docs/environment.md#what-the-model-can-change).
|
|
56
|
+
*/
|
|
57
|
+
async login(request) {
|
|
58
|
+
const provider = this.runtime.getProvider(request.provider);
|
|
59
|
+
if (provider === undefined) {
|
|
60
|
+
return {
|
|
61
|
+
provider: request.provider,
|
|
62
|
+
state: "failed",
|
|
63
|
+
models: [],
|
|
64
|
+
error: `provider ${request.provider} is not registered`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const check = await this.runtime.checkAuth(request.provider);
|
|
68
|
+
if (check !== undefined) {
|
|
69
|
+
return { provider: request.provider, state: "logged-in", models: this.modelsOf(request.provider) };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
provider: request.provider,
|
|
73
|
+
state: "logged-out",
|
|
74
|
+
models: this.modelsOf(request.provider),
|
|
75
|
+
error: request.secret === undefined
|
|
76
|
+
? `provider ${request.provider} has no credential; name the secret that holds its key`
|
|
77
|
+
: `provider ${request.provider} has no credential; configure the secret ${request.secret}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Open one window and start its first turn. */
|
|
81
|
+
async open(request) {
|
|
82
|
+
const model = this.modelOf(request.model);
|
|
83
|
+
if (model === undefined) {
|
|
84
|
+
throw arborError({
|
|
85
|
+
code: "not_found",
|
|
86
|
+
rule: "backend.model.unknown",
|
|
87
|
+
message: `backend: 模型 ${request.model} 不在目录中`,
|
|
88
|
+
nextAction: "使用 backend.models() 列出的模型 id,或在设置中把该档位映射到另一个模型。",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const results = new Map();
|
|
92
|
+
const agent = new Agent({
|
|
93
|
+
streamFn: (piModel, context, options) => this.runtime.streamSimple(piModel, context, options),
|
|
94
|
+
getApiKey: async (provider) => {
|
|
95
|
+
const auth = await this.runtime.getAuth(provider);
|
|
96
|
+
return auth?.auth.apiKey;
|
|
97
|
+
},
|
|
98
|
+
initialState: {
|
|
99
|
+
systemPrompt: systemPromptOf(request),
|
|
100
|
+
model,
|
|
101
|
+
tools: request.tools.map((tool) => piTool(tool, (args, callId) => this.callTool(request.window, tool.name, args, callId, results), this.renderer)),
|
|
102
|
+
},
|
|
103
|
+
steeringMode: "all",
|
|
104
|
+
followUpMode: "all",
|
|
105
|
+
finishTurn: () => this.control?.endRequested(request.window) === true ? { action: "end" } : undefined,
|
|
106
|
+
});
|
|
107
|
+
const first = request.firstInput ?? "";
|
|
108
|
+
const run = first === ""
|
|
109
|
+
? Promise.resolve()
|
|
110
|
+
: agent.prompt(first).then(() => undefined, () => undefined);
|
|
111
|
+
return new PiWindowSession({
|
|
112
|
+
agent,
|
|
113
|
+
window: request.window,
|
|
114
|
+
renderer: this.renderer,
|
|
115
|
+
limits: { limit: request.limits.contextTokens, reserve: request.limits.reserveTokens },
|
|
116
|
+
results,
|
|
117
|
+
run,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* One direct tool call. The call is recorded at its event before it runs, and
|
|
122
|
+
* the call itself goes through the host's tool groups - the same path a direct
|
|
123
|
+
* call takes (docs/tools.md#rules).
|
|
124
|
+
*/
|
|
125
|
+
async callTool(window, name, args, callId, results) {
|
|
126
|
+
const call = { name, args, callId };
|
|
127
|
+
if (this.control === undefined) {
|
|
128
|
+
return {
|
|
129
|
+
callId,
|
|
130
|
+
ok: false,
|
|
131
|
+
content: "",
|
|
132
|
+
error: {
|
|
133
|
+
rule: "backend.call_path.missing",
|
|
134
|
+
message: `backend: 没有提供 ${name} 的调用路径`,
|
|
135
|
+
nextAction: "在 host 中注册工具组。",
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
let at;
|
|
140
|
+
try {
|
|
141
|
+
at = await this.control.recordToolCall(window, call);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
at = undefined;
|
|
145
|
+
}
|
|
146
|
+
const result = await this.control.runToolCall(window, call, at);
|
|
147
|
+
results.set(callId, result);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
/** The model a request names, from the catalog. */
|
|
151
|
+
modelOf(id) {
|
|
152
|
+
const wanted = id.includes("/") ? id.slice(id.indexOf("/") + 1) : id;
|
|
153
|
+
return this.runtime.getModels().find((model) => model.id === wanted);
|
|
154
|
+
}
|
|
155
|
+
modelsOf(provider) {
|
|
156
|
+
return this.runtime
|
|
157
|
+
.getModels(provider)
|
|
158
|
+
.filter((model) => model.input.includes("image"))
|
|
159
|
+
.map((model) => ({ id: model.id, provider: model.provider, name: model.name, images: true }));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** The frozen prefix: the system prompt, then the memory snapshot. */
|
|
163
|
+
function systemPromptOf(request) {
|
|
164
|
+
const memory = request.prompt.memory.trim();
|
|
165
|
+
return memory === "" ? request.prompt.system : `${request.prompt.system}\n\n${memory}`;
|
|
166
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @4fu/arbor-agent-pi - the default `AgentBackend` on pi-agent-core.
|
|
3
|
+
*
|
|
4
|
+
* pi runs every window in process, so steering, tool hooks and code mode are
|
|
5
|
+
* direct calls (docs/architecture.md#stack). Nothing pi owns crosses the
|
|
6
|
+
* interface: the backend takes the core's `OpenWindowRequest` and hands back
|
|
7
|
+
* the core's `WindowSession`.
|
|
8
|
+
*/
|
|
9
|
+
export { PiAgentBackend, type PiBackendOptions, type WindowControl } from "./backend.ts";
|
|
10
|
+
export { PiWindowSession, type PiSessionOptions } from "./session.ts";
|
|
11
|
+
export { parameterSchema, piTool, toolParameters, toolResultOf } from "./tools.ts";
|
|
12
|
+
export { emptyUsage, requestTokens, tokenUsage, usageChanged } from "./usage.ts";
|
package/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @4fu/arbor-agent-pi - the default `AgentBackend` on pi-agent-core.
|
|
3
|
+
*
|
|
4
|
+
* pi runs every window in process, so steering, tool hooks and code mode are
|
|
5
|
+
* direct calls (docs/architecture.md#stack). Nothing pi owns crosses the
|
|
6
|
+
* interface: the backend takes the core's `OpenWindowRequest` and hands back
|
|
7
|
+
* the core's `WindowSession`.
|
|
8
|
+
*/
|
|
9
|
+
export { PiAgentBackend } from "./backend.js";
|
|
10
|
+
export { PiWindowSession } from "./session.js";
|
|
11
|
+
export { parameterSchema, piTool, toolParameters, toolResultOf } from "./tools.js";
|
|
12
|
+
export { emptyUsage, requestTokens, tokenUsage, usageChanged } from "./usage.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4fu/arbor-agent-pi",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The default AgentBackend: pi-agent-core in process, with its own system prompt and no pi type crossing the interface.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"default": "./index.js"
|
|
10
|
+
},
|
|
11
|
+
"./testing": {
|
|
12
|
+
"types": "./testing.d.ts",
|
|
13
|
+
"default": "./testing.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"types": "./index.d.ts",
|
|
17
|
+
"main": "./index.js",
|
|
18
|
+
"files": [
|
|
19
|
+
"backend.d.ts",
|
|
20
|
+
"backend.js",
|
|
21
|
+
"index.d.ts",
|
|
22
|
+
"index.js",
|
|
23
|
+
"session.d.ts",
|
|
24
|
+
"session.js",
|
|
25
|
+
"testing.d.ts",
|
|
26
|
+
"testing.js",
|
|
27
|
+
"tools.d.ts",
|
|
28
|
+
"tools.js",
|
|
29
|
+
"usage.d.ts",
|
|
30
|
+
"usage.js"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=24"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/4fuu/arbor.git",
|
|
39
|
+
"directory": "packages/agent-pi"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@4fu/arbor-errors": "0.1.0",
|
|
46
|
+
"@4fu/arbor-interfaces": "0.1.0",
|
|
47
|
+
"@4fu/arbor-prompt": "0.1.0",
|
|
48
|
+
"@earendil-works/pi-agent-core": "1.5.0",
|
|
49
|
+
"@earendil-works/pi-ai": "1.5.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/session.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One running window, owned by pi (docs/windows.md#lifecycle).
|
|
3
|
+
*
|
|
4
|
+
* The host sees only the core's shapes: it inserts deliveries at a tool-call
|
|
5
|
+
* boundary through pi's steering, queues follow-ups for after the run, ends the
|
|
6
|
+
* run, and reads tool calls, results and usage as they happen. Nothing pi owns
|
|
7
|
+
* crosses the interface.
|
|
8
|
+
*/
|
|
9
|
+
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";
|
|
11
|
+
import type { Renderer } from "@4fu/arbor-prompt";
|
|
12
|
+
export interface PiSessionOptions {
|
|
13
|
+
agent: Agent;
|
|
14
|
+
window: WindowRef;
|
|
15
|
+
renderer: Renderer;
|
|
16
|
+
limits: {
|
|
17
|
+
limit: number;
|
|
18
|
+
reserve: number;
|
|
19
|
+
};
|
|
20
|
+
/** The full results, keyed by call id, so the host reads the core's shape. */
|
|
21
|
+
results?: Map<string, ToolResult>;
|
|
22
|
+
/** The first turn, already started by the backend. */
|
|
23
|
+
run: Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export declare class PiWindowSession implements WindowSession {
|
|
26
|
+
readonly window: WindowRef;
|
|
27
|
+
private readonly agent;
|
|
28
|
+
private readonly renderer;
|
|
29
|
+
private readonly stream;
|
|
30
|
+
private readonly outcomePromise;
|
|
31
|
+
private resolveOutcome;
|
|
32
|
+
private unsubscribe;
|
|
33
|
+
private ended;
|
|
34
|
+
private paused;
|
|
35
|
+
private current;
|
|
36
|
+
private readonly results;
|
|
37
|
+
constructor(options: PiSessionOptions);
|
|
38
|
+
get state(): WindowState;
|
|
39
|
+
steer(delivery: Delivery): Promise<void>;
|
|
40
|
+
followUp(delivery: Delivery): Promise<void>;
|
|
41
|
+
end(handoff?: Handoff, reason?: WindowEndReason): Promise<void>;
|
|
42
|
+
pause(): Promise<void>;
|
|
43
|
+
resume(): Promise<void>;
|
|
44
|
+
usage(): TokenUsage;
|
|
45
|
+
events(): AsyncIterable<WindowEvent>;
|
|
46
|
+
outcome(): Promise<WindowOutcome>;
|
|
47
|
+
private enqueue;
|
|
48
|
+
/** Continue an idle window, so a queued delivery is picked up. */
|
|
49
|
+
private continue_;
|
|
50
|
+
private onAgentEvent;
|
|
51
|
+
/**
|
|
52
|
+
* The window's footprint: the last request's input, cache read and cache
|
|
53
|
+
* write. A running total would resend the context on every turn and cross
|
|
54
|
+
* the reminder tiers within a few turns.
|
|
55
|
+
*/
|
|
56
|
+
private trackUsage;
|
|
57
|
+
/** The core's result, kept by the backend when the call was made. */
|
|
58
|
+
private resultOf;
|
|
59
|
+
/** The outcome as it stands, for a run that ended. */
|
|
60
|
+
private outcomeOf;
|
|
61
|
+
private settle;
|
|
62
|
+
}
|
package/session.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One running window, owned by pi (docs/windows.md#lifecycle).
|
|
3
|
+
*
|
|
4
|
+
* The host sees only the core's shapes: it inserts deliveries at a tool-call
|
|
5
|
+
* boundary through pi's steering, queues follow-ups for after the run, ends the
|
|
6
|
+
* run, and reads tool calls, results and usage as they happen. Nothing pi owns
|
|
7
|
+
* crosses the interface.
|
|
8
|
+
*/
|
|
9
|
+
import { arborError } from "@4fu/arbor-errors";
|
|
10
|
+
import { renderDelivery } from "@4fu/arbor-prompt";
|
|
11
|
+
/** A queue of window events the host drains as they happen. */
|
|
12
|
+
class EventQueue {
|
|
13
|
+
pending = [];
|
|
14
|
+
waiting = [];
|
|
15
|
+
closed = false;
|
|
16
|
+
push(event) {
|
|
17
|
+
this.pending.push(event);
|
|
18
|
+
this.flush();
|
|
19
|
+
}
|
|
20
|
+
close() {
|
|
21
|
+
this.closed = true;
|
|
22
|
+
this.flush();
|
|
23
|
+
}
|
|
24
|
+
async *drain() {
|
|
25
|
+
for (;;) {
|
|
26
|
+
const next = this.pending.shift();
|
|
27
|
+
if (next !== undefined) {
|
|
28
|
+
yield next;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (this.closed)
|
|
32
|
+
return;
|
|
33
|
+
await new Promise((resolve) => this.waiting.push(resolve));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
flush() {
|
|
37
|
+
while (this.waiting.length > 0 && (this.pending.length > 0 || this.closed)) {
|
|
38
|
+
this.waiting.shift()?.();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class PiWindowSession {
|
|
43
|
+
window;
|
|
44
|
+
agent;
|
|
45
|
+
renderer;
|
|
46
|
+
stream = new EventQueue();
|
|
47
|
+
outcomePromise;
|
|
48
|
+
resolveOutcome = () => { };
|
|
49
|
+
unsubscribe;
|
|
50
|
+
ended = false;
|
|
51
|
+
paused = false;
|
|
52
|
+
current;
|
|
53
|
+
results;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.window = options.window;
|
|
56
|
+
this.agent = options.agent;
|
|
57
|
+
this.renderer = options.renderer;
|
|
58
|
+
this.results = options.results ?? new Map();
|
|
59
|
+
this.current = { used: 0, limit: options.limits.limit, reserve: options.limits.reserve };
|
|
60
|
+
this.outcomePromise = new Promise((resolve) => {
|
|
61
|
+
this.resolveOutcome = resolve;
|
|
62
|
+
});
|
|
63
|
+
this.unsubscribe = this.agent.subscribe((event) => this.onAgentEvent(event));
|
|
64
|
+
// A finished run is not a finished window: the window stays open for a
|
|
65
|
+
// related input until the host ends it (docs/windows.md#lifecycle).
|
|
66
|
+
void options.run.catch((error) => this.settle("failed", error));
|
|
67
|
+
}
|
|
68
|
+
get state() {
|
|
69
|
+
if (this.ended)
|
|
70
|
+
return "ended";
|
|
71
|
+
if (this.paused)
|
|
72
|
+
return "paused";
|
|
73
|
+
return this.agent.state.isStreaming ? "running" : "idle";
|
|
74
|
+
}
|
|
75
|
+
async steer(delivery) {
|
|
76
|
+
if (this.ended) {
|
|
77
|
+
throw arborError({
|
|
78
|
+
code: "refused",
|
|
79
|
+
rule: "window.ended",
|
|
80
|
+
message: `窗口 ${this.window} 已结束,无法再插入内容。`,
|
|
81
|
+
nextAction: "把内容发给一个还在运行的窗口,或为它开一个新窗口。",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
this.enqueue(delivery, "steer");
|
|
85
|
+
if (!this.agent.state.isStreaming)
|
|
86
|
+
await this.continue_();
|
|
87
|
+
this.stream.push({ kind: "steered", delivery });
|
|
88
|
+
}
|
|
89
|
+
async followUp(delivery) {
|
|
90
|
+
if (this.ended)
|
|
91
|
+
return;
|
|
92
|
+
this.enqueue(delivery, "followUp");
|
|
93
|
+
}
|
|
94
|
+
async end(handoff, reason = "model_ended") {
|
|
95
|
+
if (this.ended)
|
|
96
|
+
return;
|
|
97
|
+
this.agent.abort();
|
|
98
|
+
this.settle(reason, undefined, handoff);
|
|
99
|
+
}
|
|
100
|
+
async pause() {
|
|
101
|
+
this.paused = true;
|
|
102
|
+
}
|
|
103
|
+
async resume() {
|
|
104
|
+
this.paused = false;
|
|
105
|
+
}
|
|
106
|
+
usage() {
|
|
107
|
+
return this.current;
|
|
108
|
+
}
|
|
109
|
+
events() {
|
|
110
|
+
return this.stream.drain();
|
|
111
|
+
}
|
|
112
|
+
outcome() {
|
|
113
|
+
return this.outcomePromise;
|
|
114
|
+
}
|
|
115
|
+
enqueue(delivery, how) {
|
|
116
|
+
const text = renderDelivery(delivery, this.renderer);
|
|
117
|
+
const message = { role: "user", content: [{ type: "text", text }], timestamp: Date.now() };
|
|
118
|
+
if (how === "steer")
|
|
119
|
+
this.agent.steer(message);
|
|
120
|
+
else
|
|
121
|
+
this.agent.followUp(message);
|
|
122
|
+
}
|
|
123
|
+
/** Continue an idle window, so a queued delivery is picked up. */
|
|
124
|
+
async continue_() {
|
|
125
|
+
try {
|
|
126
|
+
await this.agent.continue();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Nothing queued any more, or the run already moved on.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
onAgentEvent(event) {
|
|
133
|
+
switch (event.type) {
|
|
134
|
+
case "tool_execution_start":
|
|
135
|
+
this.stream.push({
|
|
136
|
+
kind: "tool_call",
|
|
137
|
+
call: callOf(event.toolName, event.args, event.toolCallId),
|
|
138
|
+
});
|
|
139
|
+
break;
|
|
140
|
+
case "tool_execution_end":
|
|
141
|
+
this.stream.push({
|
|
142
|
+
kind: "tool_result",
|
|
143
|
+
call: callOf(event.toolName, {}, event.toolCallId),
|
|
144
|
+
result: this.resultOf(event),
|
|
145
|
+
});
|
|
146
|
+
break;
|
|
147
|
+
case "message_end":
|
|
148
|
+
if (event.message.role === "assistant")
|
|
149
|
+
this.trackUsage(event.message.usage);
|
|
150
|
+
break;
|
|
151
|
+
case "agent_end":
|
|
152
|
+
this.stream.push({ kind: "run_ended", outcome: this.outcomeOf("model_ended") });
|
|
153
|
+
break;
|
|
154
|
+
default:
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The window's footprint: the last request's input, cache read and cache
|
|
160
|
+
* write. A running total would resend the context on every turn and cross
|
|
161
|
+
* the reminder tiers within a few turns.
|
|
162
|
+
*/
|
|
163
|
+
trackUsage(usage) {
|
|
164
|
+
if (usage === undefined)
|
|
165
|
+
return;
|
|
166
|
+
const used = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
167
|
+
if (used <= this.current.used)
|
|
168
|
+
return;
|
|
169
|
+
this.current = { ...this.current, used };
|
|
170
|
+
this.stream.push({ kind: "usage", usage: this.current });
|
|
171
|
+
}
|
|
172
|
+
/** The core's result, kept by the backend when the call was made. */
|
|
173
|
+
resultOf(event) {
|
|
174
|
+
const kept = this.results.get(event.toolCallId);
|
|
175
|
+
if (kept !== undefined)
|
|
176
|
+
return kept;
|
|
177
|
+
const content = (event.result?.content ?? [])
|
|
178
|
+
.map((block) => (block.type === "text" ? (block.text ?? "") : `[${block.type}]`))
|
|
179
|
+
.join("\n");
|
|
180
|
+
const isError = event.isError === true;
|
|
181
|
+
return {
|
|
182
|
+
callId: event.toolCallId,
|
|
183
|
+
ok: isError === false,
|
|
184
|
+
content,
|
|
185
|
+
...(isError ? { error: { rule: "tool.execution", message: content.slice(0, 500) } } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** The outcome as it stands, for a run that ended. */
|
|
189
|
+
outcomeOf(reason, handoff) {
|
|
190
|
+
return {
|
|
191
|
+
window: this.window,
|
|
192
|
+
reason,
|
|
193
|
+
...(handoff === undefined ? {} : { handoff }),
|
|
194
|
+
usage: this.current,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
settle(reason, error, handoff) {
|
|
198
|
+
if (this.ended)
|
|
199
|
+
return;
|
|
200
|
+
this.ended = true;
|
|
201
|
+
this.unsubscribe?.();
|
|
202
|
+
this.unsubscribe = undefined;
|
|
203
|
+
const outcome = {
|
|
204
|
+
window: this.window,
|
|
205
|
+
reason,
|
|
206
|
+
...(handoff === undefined ? {} : { handoff }),
|
|
207
|
+
usage: this.current,
|
|
208
|
+
...(error === undefined ? {} : { error: error instanceof Error ? error.message : String(error) }),
|
|
209
|
+
};
|
|
210
|
+
this.stream.push({ kind: "ended", outcome });
|
|
211
|
+
this.stream.close();
|
|
212
|
+
this.resolveOutcome(outcome);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function callOf(name, args, callId) {
|
|
216
|
+
return { name, args: (args ?? {}), callId };
|
|
217
|
+
}
|
package/testing.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's scripted provider, for a test that runs a window without a model
|
|
3
|
+
* (docs/prompting.md#no-built-in-pi-prompt is about the prompt, not the tests).
|
|
4
|
+
*
|
|
5
|
+
* pi owns its own test doubles, so a package that tests against the pi backend
|
|
6
|
+
* reaches them through here rather than depending on pi itself.
|
|
7
|
+
*/
|
|
8
|
+
export { createModels, fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, type FauxModelDefinition, type FauxProviderHandle, type FauxResponseStep, type Models, } from "@earendil-works/pi-ai";
|
package/testing.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's scripted provider, for a test that runs a window without a model
|
|
3
|
+
* (docs/prompting.md#no-built-in-pi-prompt is about the prompt, not the tests).
|
|
4
|
+
*
|
|
5
|
+
* pi owns its own test doubles, so a package that tests against the pi backend
|
|
6
|
+
* reaches them through here rather than depending on pi itself.
|
|
7
|
+
*/
|
|
8
|
+
export { createModels, fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, } from "@earendil-works/pi-ai";
|
package/tools.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool declarations a window is opened with, as pi's tools
|
|
3
|
+
* (docs/tools.md#direct-tools).
|
|
4
|
+
*
|
|
5
|
+
* A direct tool is declared to the model and called in the turn. The core
|
|
6
|
+
* declares it once, as a `DirectToolSpec`; this mapping turns that one
|
|
7
|
+
* declaration into pi's JSON Schema, so the model, the host and the journal all
|
|
8
|
+
* read the same description. No pi type crosses back: `execute` answers with the
|
|
9
|
+
* core's own `ToolResult`, and a refusal reaches the model as the renderer's
|
|
10
|
+
* error block - the rule and the next action, one line.
|
|
11
|
+
*/
|
|
12
|
+
import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
13
|
+
import type { ParameterSpec, ToolResult } from "@4fu/arbor-interfaces";
|
|
14
|
+
import type { Renderer } from "@4fu/arbor-prompt";
|
|
15
|
+
/** One parameter's JSON Schema, from its `ParameterSpec`. */
|
|
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
|
+
/**
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resultText(result: ToolResult, renderer: Renderer): string;
|
|
24
|
+
/** The core's result as pi's. */
|
|
25
|
+
export declare function toolResultOf(result: ToolResult, renderer: Renderer): AgentToolResult<undefined>;
|
|
26
|
+
/** One direct tool, declared and wired to the core's call path. */
|
|
27
|
+
export declare function piTool(spec: {
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
parameters: Record<string, ParameterSpec>;
|
|
31
|
+
}, call: (args: Record<string, unknown>, callId: string) => Promise<ToolResult>, renderer: Renderer): AgentTool<Record<string, unknown>>;
|
package/tools.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool declarations a window is opened with, as pi's tools
|
|
3
|
+
* (docs/tools.md#direct-tools).
|
|
4
|
+
*
|
|
5
|
+
* A direct tool is declared to the model and called in the turn. The core
|
|
6
|
+
* declares it once, as a `DirectToolSpec`; this mapping turns that one
|
|
7
|
+
* declaration into pi's JSON Schema, so the model, the host and the journal all
|
|
8
|
+
* read the same description. No pi type crosses back: `execute` answers with the
|
|
9
|
+
* core's own `ToolResult`, and a refusal reaches the model as the renderer's
|
|
10
|
+
* error block - the rule and the next action, one line.
|
|
11
|
+
*/
|
|
12
|
+
import { errorBlock } from "@4fu/arbor-prompt";
|
|
13
|
+
/** One parameter's JSON Schema, from its `ParameterSpec`. */
|
|
14
|
+
export function parameterSchema(parameter) {
|
|
15
|
+
const schema = {
|
|
16
|
+
type: jsonType(parameter.type),
|
|
17
|
+
description: parameter.description,
|
|
18
|
+
};
|
|
19
|
+
if (parameter.default !== undefined)
|
|
20
|
+
schema.default = parameter.default;
|
|
21
|
+
if (parameter.type === "refs" || parameter.type === "blocks" || parameter.type === "array") {
|
|
22
|
+
schema.items = { type: "string" };
|
|
23
|
+
}
|
|
24
|
+
if (parameter.type === "object") {
|
|
25
|
+
// A nested object carries whatever its own fields say, the way a tool
|
|
26
|
+
// schema from another system does.
|
|
27
|
+
schema.additionalProperties = true;
|
|
28
|
+
}
|
|
29
|
+
return schema;
|
|
30
|
+
}
|
|
31
|
+
function jsonType(type) {
|
|
32
|
+
switch (type) {
|
|
33
|
+
case "number":
|
|
34
|
+
return "number";
|
|
35
|
+
case "boolean":
|
|
36
|
+
return "boolean";
|
|
37
|
+
case "object":
|
|
38
|
+
return "object";
|
|
39
|
+
case "refs":
|
|
40
|
+
case "blocks":
|
|
41
|
+
case "array":
|
|
42
|
+
return "array";
|
|
43
|
+
default:
|
|
44
|
+
return "string";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** The JSON Schema one tool declares to pi. */
|
|
48
|
+
export function toolParameters(parameters) {
|
|
49
|
+
const properties = {};
|
|
50
|
+
const required = [];
|
|
51
|
+
for (const [name, parameter] of Object.entries(parameters)) {
|
|
52
|
+
properties[name] = parameterSchema(parameter);
|
|
53
|
+
if (parameter.required === true)
|
|
54
|
+
required.push(name);
|
|
55
|
+
}
|
|
56
|
+
return { type: "object", properties, ...(required.length === 0 ? {} : { required }) };
|
|
57
|
+
}
|
|
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.
|
|
61
|
+
*/
|
|
62
|
+
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;
|
|
68
|
+
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
|
+
}),
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
/** The core's result as pi's. */
|
|
79
|
+
export function toolResultOf(result, renderer) {
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: resultText(result, renderer) }],
|
|
82
|
+
details: undefined,
|
|
83
|
+
isError: result.ok === false,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** One direct tool, declared and wired to the core's call path. */
|
|
87
|
+
export function piTool(spec, call, renderer) {
|
|
88
|
+
return {
|
|
89
|
+
name: spec.name,
|
|
90
|
+
label: spec.name,
|
|
91
|
+
description: spec.description,
|
|
92
|
+
parameters: toolParameters(spec.parameters),
|
|
93
|
+
execute: async (callId, params) => {
|
|
94
|
+
const result = await call((params ?? {}), callId);
|
|
95
|
+
return toolResultOf(result, renderer);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/usage.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one window's tokens are (docs/windows.md#usage-reminders).
|
|
3
|
+
*
|
|
4
|
+
* `used` is the footprint of the window's context, not a running total: the
|
|
5
|
+
* last request's input, cache read and cache write. A running total would
|
|
6
|
+
* resend the context on every turn and cross the reminder tiers in the first
|
|
7
|
+
* few turns; the footprint is what the tiers and the hard limit are about.
|
|
8
|
+
*/
|
|
9
|
+
import type { TokenUsage } from "@4fu/arbor-interfaces";
|
|
10
|
+
import type { Usage as PiUsage } from "@earendil-works/pi-ai";
|
|
11
|
+
/** The tokens one provider request carried. */
|
|
12
|
+
export declare function requestTokens(usage: PiUsage | undefined): number;
|
|
13
|
+
/** The window's usage, from the last assistant message. */
|
|
14
|
+
export declare function tokenUsage(usage: PiUsage | undefined, limit: number, reserve: number, previous?: TokenUsage): TokenUsage;
|
|
15
|
+
/** True when the reported usage moved the window forward. */
|
|
16
|
+
export declare function usageChanged(before: TokenUsage, after: TokenUsage): boolean;
|
|
17
|
+
/** The core's usage shape, for a window that never reached a model. */
|
|
18
|
+
export declare function emptyUsage(limit: number, reserve: number): TokenUsage;
|
package/usage.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one window's tokens are (docs/windows.md#usage-reminders).
|
|
3
|
+
*
|
|
4
|
+
* `used` is the footprint of the window's context, not a running total: the
|
|
5
|
+
* last request's input, cache read and cache write. A running total would
|
|
6
|
+
* resend the context on every turn and cross the reminder tiers in the first
|
|
7
|
+
* few turns; the footprint is what the tiers and the hard limit are about.
|
|
8
|
+
*/
|
|
9
|
+
/** The tokens one provider request carried. */
|
|
10
|
+
export function requestTokens(usage) {
|
|
11
|
+
if (usage === undefined)
|
|
12
|
+
return 0;
|
|
13
|
+
return usage.input + usage.cacheRead + usage.cacheWrite;
|
|
14
|
+
}
|
|
15
|
+
/** The window's usage, from the last assistant message. */
|
|
16
|
+
export function tokenUsage(usage, limit, reserve, previous) {
|
|
17
|
+
const used = Math.max(requestTokens(usage), previous?.used ?? 0);
|
|
18
|
+
return { used, limit, reserve };
|
|
19
|
+
}
|
|
20
|
+
/** True when the reported usage moved the window forward. */
|
|
21
|
+
export function usageChanged(before, after) {
|
|
22
|
+
return before.used !== after.used;
|
|
23
|
+
}
|
|
24
|
+
/** The core's usage shape, for a window that never reached a model. */
|
|
25
|
+
export function emptyUsage(limit, reserve) {
|
|
26
|
+
return { used: 0, limit, reserve };
|
|
27
|
+
}
|