@4fu/dsh-arbor-llm 0.0.0-stage → 2026.1110.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 +234 -2
- package/index.d.ts +141 -0
- package/index.js +514 -0
- package/package.json +40 -4
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,235 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-arbor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run [arbor](https://github.com/4fuu/arbor) as the agent driver inside
|
|
4
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).
|
|
5
|
+
|
|
6
|
+
DSH gives a user a session, a transcript, approvals, plugins, skills and a model
|
|
7
|
+
picker. arbor gives an agent disposable context windows over one shared journal,
|
|
8
|
+
note store and workspace, instead of one long conversation. dsh-arbor connects
|
|
9
|
+
them: one arbor host runs inside the DSH process, each DSH session that selects
|
|
10
|
+
**Arbor** is served by that host, and the window's reply is written back to the
|
|
11
|
+
DSH session as an ordinary assistant message. A DSH session that does not select
|
|
12
|
+
Arbor keeps running on DSH's own embedded AgentLoop, in the same process.
|
|
13
|
+
|
|
14
|
+
> **Status.** This is a research-grade integration, not a DSH release. It is
|
|
15
|
+
> pinned to one DSH version and relies on DSH internals. Read
|
|
16
|
+
> [Versions and compatibility](docs/versions.md) before installing it on
|
|
17
|
+
> anything you care about. The packages are published under `@4fu` at
|
|
18
|
+
> `2026.1110.0`; the bundle and the font plugin install independently from npm.
|
|
19
|
+
|
|
20
|
+
## What an arbor session changes
|
|
21
|
+
|
|
22
|
+
Choosing **Arbor** in a DSH composer creates the session under the `arbor` agent
|
|
23
|
+
preset. That session then runs on arbor, and the following are true for it:
|
|
24
|
+
|
|
25
|
+
- **One window per turn, one chain per session.** A DSH session is one arbor
|
|
26
|
+
conversation: its first turn opens a window, a window that ends hands off to a
|
|
27
|
+
continuation, and the next turn drives the chain. The journal and note store
|
|
28
|
+
live under `$DSH_HOME/arbor/` and survive a restart.
|
|
29
|
+
- **arbor's workspace, not DSH's sandbox.** A window works in arbor's shared
|
|
30
|
+
folder (`$DSH_HOME/arbor/shared`). When the `ovnfs` binary is available a
|
|
31
|
+
window opens a copy-on-write checkout and merges back with `apply`; otherwise
|
|
32
|
+
arbor uses a plain directory. **There is no DSH sandbox for an arbor session:**
|
|
33
|
+
arbor's file tools and commands run on the host, and arbor's own secret scrub
|
|
34
|
+
is the only protection on what reaches the model.
|
|
35
|
+
- **arbor's projects.** The shared folder's project folders are the DSH project
|
|
36
|
+
list for arbor composers. A directory the user adds outside the shared folder
|
|
37
|
+
is recorded as an external pointer for that session and used in place.
|
|
38
|
+
- **arbor's prompt and tools.** DSH's official prompt sections are decided one by
|
|
39
|
+
one; the ones arbor replaces are absent, third-party sections go into a
|
|
40
|
+
`<dsh>` data block, and a trusted `<harness>` section says what remains true of
|
|
41
|
+
DSH. DSH tools are offered to the model as arbor external tools, and arbor's
|
|
42
|
+
own tools replace DSH tools with the same capability.
|
|
43
|
+
- **The Arbor page.** A permanent **Arbor** entry in DSH's left navigation opens
|
|
44
|
+
the whole-host overview and one tab per arbor session: windows, chains,
|
|
45
|
+
handoffs, projects, the board, notes, `/env`, schedules and arbor's search.
|
|
46
|
+
An arbor composer hides DSH's sandbox access-mode switch, because that switch
|
|
47
|
+
does not apply to it.
|
|
48
|
+
|
|
49
|
+
## What stays untouched in ordinary DSH sessions
|
|
50
|
+
|
|
51
|
+
A session that does not select Arbor is an ordinary DSH session. It keeps:
|
|
52
|
+
|
|
53
|
+
- DSH's embedded AgentLoop, session format, turn boundaries and streaming path;
|
|
54
|
+
- DSH's sandbox policy and its `ctx.fs` / `ctx.shell` file and command tools;
|
|
55
|
+
- DSH's project management, working directory and prompt assembly;
|
|
56
|
+
- its composer, sandbox access-mode switch, header and sidebar behavior.
|
|
57
|
+
|
|
58
|
+
It gets **no Arbor tab** and no arbor windows. The only shared surface is the
|
|
59
|
+
permanent Arbor entry in the left navigation, which simply does not list that
|
|
60
|
+
session. The routing registry replaces DSH's base `agent` row so it can send
|
|
61
|
+
`agentPreset: 'arbor'` sessions to arbor; every other session goes to the
|
|
62
|
+
official loop. Approvals still use DSH's approval path for both kinds of
|
|
63
|
+
session, and arbor's windows are registered so DSH's sidebar leaves them out.
|
|
64
|
+
|
|
65
|
+
## Screenshots
|
|
66
|
+
|
|
67
|
+
| Overview, light | Overview, dark |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
|  |  |
|
|
70
|
+
| **Session tab, light** | **Session tab, dark** |
|
|
71
|
+
|  |  |
|
|
72
|
+
|
|
73
|
+
These are the web profile at 1440 px wide with the optional
|
|
74
|
+
[font plugin](https://github.com/4fuu/dsh-arbor-fonts) installed: ChillDIN
|
|
75
|
+
Gothic for labels and ChillDIN for numerals, while body text and code keep
|
|
76
|
+
DSH's fonts. The same overview without the plugin is
|
|
77
|
+
[here](docs/screenshots/overview-without-fonts.png).
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
Requirements: Node.js 24.9 or newer and DeepSeek Harness `dsh-v0.2.2-rc.1`.
|
|
82
|
+
The bundle is published with all packages it depends on; nothing has to be
|
|
83
|
+
built or linked.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm install -g @deepseek-ai/dsh@0.2.2-rc.1
|
|
87
|
+
dsh plugin --profile web add @4fu/dsh-arbor-bundle@2026.1110.0
|
|
88
|
+
dsh web
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`dsh plugin` adds the bundle to the profile's `dsh.profile.bundles` and installs
|
|
92
|
+
its packages into the profile. Pick **Arbor** in a composer and send a message.
|
|
93
|
+
The optional [font plugin](https://github.com/4fuu/dsh-arbor-fonts) installs the
|
|
94
|
+
same way:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
dsh plugin --profile web add @4fu/dsh-arbor-fonts@2026.1110.0
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### From a checkout
|
|
101
|
+
|
|
102
|
+
The proofs and development use the TypeScript sources directly; that checkout is
|
|
103
|
+
what the scripts and the pin check run against.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
git clone https://github.com/4fuu/dsh-arbor
|
|
107
|
+
cd dsh-arbor
|
|
108
|
+
pnpm install --frozen-lockfile
|
|
109
|
+
pnpm check # tsc --noEmit over every package and proof
|
|
110
|
+
pnpm proofs # 29 proofs against the scripted model
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The first install may ask pnpm to approve dependency build scripts. Do not
|
|
114
|
+
approve them by hand: the repository's `pnpm-workspace.yaml` already denies the
|
|
115
|
+
six scripts it pulls in, each with the reason, and `pnpm install` finishes
|
|
116
|
+
cleanly with that policy.
|
|
117
|
+
|
|
118
|
+
### Hand the install to a model
|
|
119
|
+
|
|
120
|
+
[docs/install-for-agents.md](docs/install-for-agents.md) is a step-by-step guide
|
|
121
|
+
written for an AI agent: install from npm or a checkout, run the proofs, drive a
|
|
122
|
+
live turn with a restart, boot the web profile, and inspect what ran. Give a
|
|
123
|
+
model this prompt, editing the bracketed parts or dropping them:
|
|
124
|
+
|
|
125
|
+
```text copy Copy Install Prompt
|
|
126
|
+
Install dsh-arbor (https://github.com/4fuu/dsh-arbor) in this environment [in a
|
|
127
|
+
scratch DSH home under /tmp] and show me that it works: follow
|
|
128
|
+
docs/install-for-agents.md, install @4fu/dsh-arbor-bundle@2026.1110.0 into a DSH
|
|
129
|
+
web profile, pick Arbor for one session, and [also run the proof suite from a
|
|
130
|
+
checkout with pnpm install --frozen-lockfile, pnpm check and pnpm proofs]. Report
|
|
131
|
+
which checks passed, what the live session answered, and anything that did not
|
|
132
|
+
work. Fix only what is yours to fix and tell me what you changed.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Use it in DSH
|
|
136
|
+
|
|
137
|
+
The npm install above adds the bundle to a DSH web profile. Pick **Arbor** in a
|
|
138
|
+
composer and send a message: the session is created under the preset, its window
|
|
139
|
+
and chain appear under the **Arbor** entry, and an ordinary session started
|
|
140
|
+
beside it keeps DSH's own composer and sandbox behavior. A checkout can also be
|
|
141
|
+
wired by hand; [section 7 of the install guide](docs/install-for-agents.md#7-wiring-the-bundle-into-a-real-dsh-installation)
|
|
142
|
+
shows the profile layout.
|
|
143
|
+
|
|
144
|
+
The one-shot `dsh <task>` runner cannot drive an arbor-preset session: DSH's
|
|
145
|
+
runner refuses to adopt a session that runs under a preset. Use the web client,
|
|
146
|
+
the SDK path the proofs use, or a remote `agent-preset/select` on a blank.
|
|
147
|
+
|
|
148
|
+
## Pinned DSH version
|
|
149
|
+
|
|
150
|
+
DSH is pinned to **`dsh-v0.2.2-rc.1`** (npm
|
|
151
|
+
`@deepseek-ai/dsh@0.2.2-rc.1`, commit `66491b05e3` of
|
|
152
|
+
`deepseek-ai/deepseek-harness`). Every `@deepseek-ai/dsh*` dependency is an
|
|
153
|
+
exact version, every package under `packages/` declares
|
|
154
|
+
`engines.dsh: 0.2.2-rc.1`, and `pnpm check:pin` checks both plus the prompt
|
|
155
|
+
bridge's `DSH_PINNED_VERSION`.
|
|
156
|
+
|
|
157
|
+
A different DSH version is unsupported. The bundle depends on DSH's row names,
|
|
158
|
+
its one-`AgentFactory` rule, its prompt-section registry, its client slot
|
|
159
|
+
registration and its exact Fetch registry; a move in any of them can fail the
|
|
160
|
+
boot, leave an official prompt section undecided, or stop the Arbor page from
|
|
161
|
+
registering. `proofs/12-official-sections.ts` and `proofs/18-picker-pin.ts`
|
|
162
|
+
are the behavioral checks. The canonical pin and the upgrade procedure are in
|
|
163
|
+
[Versions and compatibility](docs/versions.md).
|
|
164
|
+
|
|
165
|
+
## Optional: the font plugin
|
|
166
|
+
|
|
167
|
+
[dsh-arbor-fonts](https://github.com/4fuu/dsh-arbor-fonts) is a separate DSH
|
|
168
|
+
client plugin that changes only the typography of the Arbor page. It sets
|
|
169
|
+
ChillDIN Gothic for display labels and ChillDIN for numerals, leaves body text
|
|
170
|
+
and code on DSH's fonts, and does nothing on ordinary pages. It is optional and
|
|
171
|
+
installs into the same DSH profile:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
dsh plugin --profile web add @4fu/dsh-arbor-fonts@2026.1110.0
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The fonts are licensed under the SIL Open Font License 1.1; the plugin ships the
|
|
178
|
+
license text, per-file hashes and attribution.
|
|
179
|
+
|
|
180
|
+
## Limits worth knowing
|
|
181
|
+
|
|
182
|
+
- The judge is off: a model-based judge cannot run through the DSH backend yet.
|
|
183
|
+
- The 12 bundle packages are published together at one version; install and
|
|
184
|
+
upgrade the bundle as a unit, because its packages name each other exactly.
|
|
185
|
+
- `ovnfs` is optional. Without it arbor uses the plain-directory workspace, and a
|
|
186
|
+
window no longer gets a copy-on-write checkout.
|
|
187
|
+
- The web page's numbers and layout are read from arbor's journal and API; it is
|
|
188
|
+
not a generic DSH dashboard, and it deliberately shows only arbor sessions.
|
|
189
|
+
- The prompt bridge, the UI and the picker filter are pinned to DSH's internals,
|
|
190
|
+
not to a stable public API. See [Versions and compatibility](docs/versions.md).
|
|
191
|
+
|
|
192
|
+
## Documentation
|
|
193
|
+
|
|
194
|
+
- [Installing and inspecting dsh-arbor](docs/install-for-agents.md) — the
|
|
195
|
+
agent-facing install, proof, live-turn, web-profile and wiring guide.
|
|
196
|
+
- [Versions and compatibility](docs/versions.md) — the DSH and arbor pins, and
|
|
197
|
+
what breaks on another DSH version.
|
|
198
|
+
- [B0 findings](docs/b0-findings.md), [B3 findings](docs/b3-findings.md) and
|
|
199
|
+
[B3b projects and sandbox](docs/b3b-projects-and-sandbox.md) — historical
|
|
200
|
+
records of the integration's decisions, with the DSH source locations each
|
|
201
|
+
one rests on. They describe earlier stages; the install guide is the current
|
|
202
|
+
status.
|
|
203
|
+
- [Layout and proofs](#layout) below for a quick map of the packages.
|
|
204
|
+
|
|
205
|
+
## Layout
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
packages/
|
|
209
|
+
arbor-host/ one Arbor host per process: SQLite journal and notes,
|
|
210
|
+
settings, window lifecycle, workspace and environment
|
|
211
|
+
arbor-driver/ the routing AgentRegistry, AgentFactory, session agent,
|
|
212
|
+
inbox and stream mirrors, and the journal relay
|
|
213
|
+
arbor-llm/ the arbor AgentBackend over DSH's ctx.llm and ctx.tools
|
|
214
|
+
arbor-prompt/ <harness>, the <dsh> data block, and the section table
|
|
215
|
+
arbor-tools/ DSH tools as arbor external tools, same-name replacement
|
|
216
|
+
arbor-workspace/ arbor's workspace over the shared folder (ovnfs or dir)
|
|
217
|
+
arbor-env/ /env, with DSH's skills and MCP servers mounted read-only
|
|
218
|
+
arbor-bundle/ the installable patch that mounts the rows and UI
|
|
219
|
+
arbor-facts/ the read face behind the Arbor page and its Fetch routes
|
|
220
|
+
arbor-ui/ the Arbor page, session tabs, copy and composer gate
|
|
221
|
+
arbor-ui-workspace/
|
|
222
|
+
the per-preset hero project picker
|
|
223
|
+
arbor-preset/ the agent-preset row that puts Arbor in the roster
|
|
224
|
+
arbor-testkit/ the boot harness, memory journal and scripted model
|
|
225
|
+
patches/ overlay layers a proof applies above the shipped profile
|
|
226
|
+
proofs/ one runnable proof per integration point
|
|
227
|
+
docs/ install, versions, screenshots and historical findings
|
|
228
|
+
scripts/ the DSH pin check, the release build and the publish dry run
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
MIT. See [LICENSE](LICENSE). The optional font plugin vendors fonts under the
|
|
234
|
+
SIL Open Font License 1.1; its repository carries the license text and
|
|
235
|
+
attribution.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arbor `AgentBackend` that runs each window through DSH.
|
|
3
|
+
*
|
|
4
|
+
* One DSH process mounts one Arbor host (see `@4fu/dsh-arbor-host`), so this
|
|
5
|
+
* backend is one object that serves every session's windows. Per window it
|
|
6
|
+
* holds the frozen epoch the host opened it with — the rendered system prompt
|
|
7
|
+
* and the declared direct tools, DSH's among them — plus the window's live
|
|
8
|
+
* event bus. Per window it also holds the DSH session agent whose turns drive
|
|
9
|
+
* it, bound by the driver when the session is created or resumed.
|
|
10
|
+
*
|
|
11
|
+
* This is the seam where arbor's window lifecycle meets DSH's services:
|
|
12
|
+
*
|
|
13
|
+
* - `open(request)` captures the window's frozen epoch and returns the window
|
|
14
|
+
* session the host watches. It needs no agent: a window a restart continued
|
|
15
|
+
* opens before any session is resumed onto it.
|
|
16
|
+
* - `modelCall` — used by the DSH-side session agent for each of its steps —
|
|
17
|
+
* resolves the route, logs the `request/header`, and streams through
|
|
18
|
+
* `ctx.llm.stream`; the request itself is derived from the session log by
|
|
19
|
+
* the agent's owner.
|
|
20
|
+
* - `externalTools()` hands DSH's registered tools to arbor, so the model
|
|
21
|
+
* declares and calls them like any other direct tool; execution lands back
|
|
22
|
+
* in `ctx.tools.execute` with the session agent of the turn that is running,
|
|
23
|
+
* which is what routes approvals through DSH's approval path and appends the
|
|
24
|
+
* durable `tool/call` + `tool/result` pair to that session's log.
|
|
25
|
+
*
|
|
26
|
+
* The window session is a live event bus: the agent publishes text deltas,
|
|
27
|
+
* tool calls and run boundaries, and the host's `watch` loop consumes them
|
|
28
|
+
* like any backend's.
|
|
29
|
+
*/
|
|
30
|
+
import type { Agent, AgentRegistry, ModelSelection } from '@deepseek-ai/dsh-agent';
|
|
31
|
+
import type { LlmRuntime, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm';
|
|
32
|
+
import type { ContextFormed } from '@deepseek-ai/dsh-llm';
|
|
33
|
+
import type { ToolsService } from '@deepseek-ai/dsh-tools';
|
|
34
|
+
import type { AgentBackend, DirectToolSpec, ExternalTool, LoginResult, ModelInfo, OpenWindowRequest, WindowEndReason, WindowEvent, WindowRef, WindowSession } from '@4fu/arbor-interfaces';
|
|
35
|
+
import type { Renderer } from '@4fu/arbor-prompt';
|
|
36
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
37
|
+
interface MessageSourceMap {
|
|
38
|
+
'arbor-delivery': {
|
|
39
|
+
kind: 'arbor-delivery';
|
|
40
|
+
} & ContextFormed;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** One model call's inputs, supplied by the DSH-side session agent. */
|
|
44
|
+
export interface DshModelCall {
|
|
45
|
+
/** The window whose frozen epoch this call runs under. */
|
|
46
|
+
readonly window: WindowRef;
|
|
47
|
+
/** The session agent whose log the request is derived from. */
|
|
48
|
+
readonly agent: Agent;
|
|
49
|
+
readonly selection: Readonly<ModelSelection>;
|
|
50
|
+
readonly maxTokens?: number;
|
|
51
|
+
readonly position: {
|
|
52
|
+
turn: number;
|
|
53
|
+
step: number;
|
|
54
|
+
};
|
|
55
|
+
readonly signal: AbortSignal;
|
|
56
|
+
/** One streamed chunk, for the durable attempt and the window's live output. */
|
|
57
|
+
readonly onChunk: (chunk: StreamChunk) => void;
|
|
58
|
+
}
|
|
59
|
+
/** One model call's settled facts. */
|
|
60
|
+
export interface DshModelOutcome {
|
|
61
|
+
readonly usage?: TokenUsage;
|
|
62
|
+
readonly finish: string;
|
|
63
|
+
}
|
|
64
|
+
/** Inputs for {@link createDshBackend}. */
|
|
65
|
+
export interface DshBackendOptions {
|
|
66
|
+
/** DSH's LLM runtime (`ctx.llm`). */
|
|
67
|
+
readonly llm: LlmRuntime;
|
|
68
|
+
/** DSH's tool registry (`ctx.tools`). */
|
|
69
|
+
readonly tools: ToolsService;
|
|
70
|
+
/**
|
|
71
|
+
* DSH's agent registry (`ctx.agents`). One backend serves every session, so
|
|
72
|
+
* the agent a tool call runs as is read per call from the initiating agent of
|
|
73
|
+
* the turn executing it — the same attribution DSH itself uses.
|
|
74
|
+
*/
|
|
75
|
+
readonly agents: AgentRegistry;
|
|
76
|
+
/**
|
|
77
|
+
* The host's one renderer, read lazily because the host is built after this
|
|
78
|
+
* backend is. A delivery's blocks were built by the host with its own table
|
|
79
|
+
* and block kinds, so rendering them with any other renderer would drop the
|
|
80
|
+
* fixed lines and the kinds a plugin registered.
|
|
81
|
+
*/
|
|
82
|
+
readonly renderer: () => Renderer;
|
|
83
|
+
}
|
|
84
|
+
/** The DSH-backed arbor agent backend: one per process, one entry per window. */
|
|
85
|
+
export declare class DshBackend implements AgentBackend {
|
|
86
|
+
readonly name = "dsh";
|
|
87
|
+
private readonly windows;
|
|
88
|
+
private readonly waiting;
|
|
89
|
+
private readonly headers;
|
|
90
|
+
private readonly options;
|
|
91
|
+
constructor(options: DshBackendOptions);
|
|
92
|
+
/** Resolves once `open` has captured this window's frozen epoch. */
|
|
93
|
+
whenOpen(window: WindowRef): Promise<void>;
|
|
94
|
+
models(): Promise<ModelInfo[]>;
|
|
95
|
+
login(): Promise<LoginResult>;
|
|
96
|
+
externalTools(): readonly ExternalTool[];
|
|
97
|
+
/**
|
|
98
|
+
* The names of the DSH tools this backend hands to arbor, for the
|
|
99
|
+
* `<harness>` section's revision and its tool-usage lines. The same filter
|
|
100
|
+
* {@link externalTools} applies, so a name here is a name the model can call.
|
|
101
|
+
*/
|
|
102
|
+
externalToolNames(): readonly string[];
|
|
103
|
+
open(request: OpenWindowRequest): Promise<WindowSession>;
|
|
104
|
+
/**
|
|
105
|
+
* Bind the DSH session agent whose turns drive this window. A window a
|
|
106
|
+
* restart continued opens before its session is resumed, so the binding is
|
|
107
|
+
* made when the session comes back, not when the window does.
|
|
108
|
+
*/
|
|
109
|
+
bind(window: WindowRef, agent: Agent): void;
|
|
110
|
+
/**
|
|
111
|
+
* Drop one window's binding, the way a session's disposal does: the window
|
|
112
|
+
* itself is the host's and lives out arbor's own idle policy.
|
|
113
|
+
*/
|
|
114
|
+
unbind(window: WindowRef): void;
|
|
115
|
+
/** Drop one ended window's state, so nothing retains its agent or its bus. */
|
|
116
|
+
private release;
|
|
117
|
+
/** The window's live events, observable beside the host's own watch loop. */
|
|
118
|
+
events(window: WindowRef): AsyncIterable<WindowEvent>;
|
|
119
|
+
/** The system prompt this window's epoch rendered. */
|
|
120
|
+
systemPrompt(window: WindowRef): string;
|
|
121
|
+
/** The direct tools declared to the model for this window, as they stand now. */
|
|
122
|
+
toolSpecs(window: WindowRef): readonly DirectToolSpec[];
|
|
123
|
+
/** Publish one live stream event to the window. */
|
|
124
|
+
publish(window: WindowRef, event: WindowEvent): void;
|
|
125
|
+
/** The turn's work settled: the window's run ends and it stays open for more. */
|
|
126
|
+
finishRun(window: WindowRef): void;
|
|
127
|
+
/** End the window, the way a host shutdown does. */
|
|
128
|
+
endWindow(window: WindowRef, reason?: WindowEndReason): Promise<void>;
|
|
129
|
+
private requireEntry;
|
|
130
|
+
/**
|
|
131
|
+
* One model call for the DSH-side agent's current step: resolve the route,
|
|
132
|
+
* log the request header, and stream through `ctx.llm`. The request's
|
|
133
|
+
* messages come from the session log, exactly like the official loop's
|
|
134
|
+
* `buildRequest`.
|
|
135
|
+
*/
|
|
136
|
+
modelCall(input: DshModelCall): Promise<DshModelOutcome>;
|
|
137
|
+
/** Log the resolved request header the way `ReactLoopAgent.buildRequest` does. */
|
|
138
|
+
private logRequestHeader;
|
|
139
|
+
}
|
|
140
|
+
/** Create the DSH-backed arbor agent backend. */
|
|
141
|
+
export declare function createDshBackend(options: DshBackendOptions): DshBackend;
|
package/index.js
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arbor `AgentBackend` that runs each window through DSH.
|
|
3
|
+
*
|
|
4
|
+
* One DSH process mounts one Arbor host (see `@4fu/dsh-arbor-host`), so this
|
|
5
|
+
* backend is one object that serves every session's windows. Per window it
|
|
6
|
+
* holds the frozen epoch the host opened it with — the rendered system prompt
|
|
7
|
+
* and the declared direct tools, DSH's among them — plus the window's live
|
|
8
|
+
* event bus. Per window it also holds the DSH session agent whose turns drive
|
|
9
|
+
* it, bound by the driver when the session is created or resumed.
|
|
10
|
+
*
|
|
11
|
+
* This is the seam where arbor's window lifecycle meets DSH's services:
|
|
12
|
+
*
|
|
13
|
+
* - `open(request)` captures the window's frozen epoch and returns the window
|
|
14
|
+
* session the host watches. It needs no agent: a window a restart continued
|
|
15
|
+
* opens before any session is resumed onto it.
|
|
16
|
+
* - `modelCall` — used by the DSH-side session agent for each of its steps —
|
|
17
|
+
* resolves the route, logs the `request/header`, and streams through
|
|
18
|
+
* `ctx.llm.stream`; the request itself is derived from the session log by
|
|
19
|
+
* the agent's owner.
|
|
20
|
+
* - `externalTools()` hands DSH's registered tools to arbor, so the model
|
|
21
|
+
* declares and calls them like any other direct tool; execution lands back
|
|
22
|
+
* in `ctx.tools.execute` with the session agent of the turn that is running,
|
|
23
|
+
* which is what routes approvals through DSH's approval path and appends the
|
|
24
|
+
* durable `tool/call` + `tool/result` pair to that session's log.
|
|
25
|
+
*
|
|
26
|
+
* The window session is a live event bus: the agent publishes text deltas,
|
|
27
|
+
* tool calls and run boundaries, and the host's `watch` loop consumes them
|
|
28
|
+
* like any backend's.
|
|
29
|
+
*/
|
|
30
|
+
import { BlockAssembler, createDeveloperMessage, createUserMessage, markAgentLoopRequest } from '@deepseek-ai/dsh-llm';
|
|
31
|
+
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session';
|
|
32
|
+
import { renderDelivery } from '@4fu/arbor-prompt';
|
|
33
|
+
import { dshExternalTools, schemaOf } from '@4fu/dsh-arbor-tools';
|
|
34
|
+
/** A minimal publish/subscribe bus for one window's events. */
|
|
35
|
+
class WindowEventBus {
|
|
36
|
+
queues = new Set();
|
|
37
|
+
closed = false;
|
|
38
|
+
publish(event) {
|
|
39
|
+
if (this.closed)
|
|
40
|
+
return;
|
|
41
|
+
for (const queue of [...this.queues])
|
|
42
|
+
queue.push(event);
|
|
43
|
+
}
|
|
44
|
+
close() {
|
|
45
|
+
if (this.closed)
|
|
46
|
+
return;
|
|
47
|
+
this.closed = true;
|
|
48
|
+
for (const queue of [...this.queues])
|
|
49
|
+
queue.close();
|
|
50
|
+
this.queues.clear();
|
|
51
|
+
}
|
|
52
|
+
subscribe() {
|
|
53
|
+
const queues = this.queues;
|
|
54
|
+
const pending = [];
|
|
55
|
+
const waiters = [];
|
|
56
|
+
let done = false;
|
|
57
|
+
const wake = () => {
|
|
58
|
+
const next = waiters.shift();
|
|
59
|
+
if (next !== undefined)
|
|
60
|
+
next();
|
|
61
|
+
};
|
|
62
|
+
const queue = {
|
|
63
|
+
push: (event) => {
|
|
64
|
+
pending.push(event);
|
|
65
|
+
wake();
|
|
66
|
+
},
|
|
67
|
+
close: () => {
|
|
68
|
+
done = true;
|
|
69
|
+
wake();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
this.queues.add(queue);
|
|
73
|
+
return {
|
|
74
|
+
[Symbol.asyncIterator]() {
|
|
75
|
+
return {
|
|
76
|
+
async next() {
|
|
77
|
+
while (true) {
|
|
78
|
+
const event = pending.shift();
|
|
79
|
+
if (event !== undefined)
|
|
80
|
+
return { value: event, done: false };
|
|
81
|
+
if (done)
|
|
82
|
+
return { value: undefined, done: true };
|
|
83
|
+
await new Promise(resolve => { waiters.push(resolve); });
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
async return() {
|
|
87
|
+
queues.delete(queue);
|
|
88
|
+
return { value: undefined, done: true };
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** One running arbor window backed by a DSH session. */
|
|
96
|
+
class DshWindowSession {
|
|
97
|
+
window;
|
|
98
|
+
bus;
|
|
99
|
+
ended = Promise.withResolvers();
|
|
100
|
+
/**
|
|
101
|
+
* The backend session's own state, not the host's window record.
|
|
102
|
+
*
|
|
103
|
+
* Arbor opens a window idle: opening it starts no run, and a continuation a
|
|
104
|
+
* restart opened before its session resumed stays idle until one does. This
|
|
105
|
+
* session starts a DSH turn when the bridge calls `modelCall`, so the turn is
|
|
106
|
+
* what makes this state running, and the turn's end returns it to idle. The
|
|
107
|
+
* host's record moves on the events this session publishes; the two are
|
|
108
|
+
* related but never copied from one another, because a host that says idle
|
|
109
|
+
* does not mean this session cannot accept a delivery.
|
|
110
|
+
*/
|
|
111
|
+
currentState = 'idle';
|
|
112
|
+
/** The state a paused session returns to when the host resumes it. */
|
|
113
|
+
resumeState = 'idle';
|
|
114
|
+
usageValue = { used: 0, limit: 0, reserve: 0 };
|
|
115
|
+
onEnd;
|
|
116
|
+
/** The window's frozen epoch, whose declared tools a `refreshTools` replaces. */
|
|
117
|
+
epoch;
|
|
118
|
+
/** The DSH session agent whose turns drive this window, once one is bound. */
|
|
119
|
+
driver;
|
|
120
|
+
/** How a delivery is rendered into the one user-role input the model reads. */
|
|
121
|
+
render;
|
|
122
|
+
constructor(window, bus, onEnd, render) {
|
|
123
|
+
this.window = window;
|
|
124
|
+
this.bus = bus;
|
|
125
|
+
this.onEnd = onEnd;
|
|
126
|
+
this.render = render;
|
|
127
|
+
}
|
|
128
|
+
get state() {
|
|
129
|
+
return this.currentState;
|
|
130
|
+
}
|
|
131
|
+
/** Capture the window's frozen epoch: its prompt, its declared tools, its limits. */
|
|
132
|
+
open(request) {
|
|
133
|
+
this.epoch = request;
|
|
134
|
+
this.usageValue.limit = request.limits.contextTokens;
|
|
135
|
+
this.usageValue.reserve = request.limits.reserveTokens;
|
|
136
|
+
}
|
|
137
|
+
/** Bind the DSH session agent whose turns drive this window. */
|
|
138
|
+
bind(agent) {
|
|
139
|
+
this.driver = agent;
|
|
140
|
+
}
|
|
141
|
+
/** Drop the binding, the way a session's disposal does. */
|
|
142
|
+
unbind() {
|
|
143
|
+
this.driver = undefined;
|
|
144
|
+
}
|
|
145
|
+
/** One live event of this window, from the session agent that drives it. */
|
|
146
|
+
publish(event) {
|
|
147
|
+
if (this.currentState === 'ended')
|
|
148
|
+
return;
|
|
149
|
+
// The run's own events are what make the backend session running. Usage is
|
|
150
|
+
// not one of them: it is reported when a message ends, which can be the
|
|
151
|
+
// last thing before the run's end, so it must not wake a quiet window.
|
|
152
|
+
if (event.kind !== 'usage' && event.kind !== 'run_ended' && event.kind !== 'ended') {
|
|
153
|
+
this.beginRun();
|
|
154
|
+
}
|
|
155
|
+
this.bus.publish(event);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The DSH agent started a turn in this window. The host learns a run is
|
|
159
|
+
* active from the first event this session publishes; this state is the
|
|
160
|
+
* backend's own, and starts the window's `state` at running before that
|
|
161
|
+
* first event arrives.
|
|
162
|
+
*/
|
|
163
|
+
beginRun() {
|
|
164
|
+
if (this.currentState === 'ended' || this.currentState === 'paused')
|
|
165
|
+
return;
|
|
166
|
+
this.currentState = 'running';
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The turn ended and no turn is in progress now. The window stays open for
|
|
170
|
+
* the session's next turn, exactly as arbor's own record returns to idle.
|
|
171
|
+
*/
|
|
172
|
+
finishRun() {
|
|
173
|
+
if (this.currentState === 'ended' || this.currentState === 'paused')
|
|
174
|
+
return;
|
|
175
|
+
this.currentState = 'idle';
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Insert at the next tool-call boundary, through DSH's own steering.
|
|
179
|
+
*
|
|
180
|
+
* The mapping is the one arbor's `AgentBackend` contract names for a backend
|
|
181
|
+
* that is not pi: `agent.inject` for a step boundary and the agent's steering
|
|
182
|
+
* for the next turn. `agent.steer` covers both, because it splices into the
|
|
183
|
+
* next step while a turn runs and starts one when the window is idle — which
|
|
184
|
+
* is what a delivery arriving for a quiet window wants.
|
|
185
|
+
*/
|
|
186
|
+
async steer(delivery) {
|
|
187
|
+
this.deliver(delivery, 'steer');
|
|
188
|
+
}
|
|
189
|
+
/** Queue for after the run ends: the delivery becomes the next turn's input. */
|
|
190
|
+
async followUp(delivery) {
|
|
191
|
+
this.deliver(delivery, 'followUp');
|
|
192
|
+
}
|
|
193
|
+
deliver(delivery, how) {
|
|
194
|
+
// The host never steers into a window it has ended or paused, and a window
|
|
195
|
+
// whose session is not bound yet has no inbox to splice into: a restart
|
|
196
|
+
// continuation opens before its session comes back. An idle window is not
|
|
197
|
+
// one of those: the host sends its related input through `followUp`, which
|
|
198
|
+
// starts the next turn, so only an ended window drops a delivery.
|
|
199
|
+
if (this.currentState === 'ended')
|
|
200
|
+
return;
|
|
201
|
+
const agent = this.driver;
|
|
202
|
+
if (agent === undefined)
|
|
203
|
+
return;
|
|
204
|
+
const text = this.render(delivery);
|
|
205
|
+
if (text === '')
|
|
206
|
+
return;
|
|
207
|
+
const message = createUserMessage({
|
|
208
|
+
content: [{ type: 'text', text }],
|
|
209
|
+
source: { kind: 'arbor-delivery' },
|
|
210
|
+
});
|
|
211
|
+
// Arbor marks every delivery with what it is to the turn it enters: a
|
|
212
|
+
// context delivery is read by the next request and must never start one, and
|
|
213
|
+
// an input delivery is what continues a quiet window. The mark is
|
|
214
|
+
// authoritative; the delivery kind is not consulted. Steering context into
|
|
215
|
+
// a quiet window would wake its session to read a diff, which is a turn the
|
|
216
|
+
// user did not ask for and the model has nothing to do in.
|
|
217
|
+
//
|
|
218
|
+
// An input while this session is idle is the next turn's input: DSH's
|
|
219
|
+
// `followup` is what wakes a quiet agent, and its steering target belongs
|
|
220
|
+
// to a turn that is already running. A delivery that arrives while a turn
|
|
221
|
+
// runs goes in at the next step (`steer`), or after the run (`followUp`).
|
|
222
|
+
if (delivery.role === 'context')
|
|
223
|
+
agent.inject(message);
|
|
224
|
+
else if (how === 'steer' && this.currentState === 'running')
|
|
225
|
+
agent.steer(message);
|
|
226
|
+
else
|
|
227
|
+
agent.followup(message);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Replace the tools this window declared, so a tool that appeared is callable
|
|
231
|
+
* here and one that was removed is not. The window's prefix is not rebuilt:
|
|
232
|
+
* the `tools_delta` it is steered tells it what moved.
|
|
233
|
+
*/
|
|
234
|
+
async refreshTools(tools) {
|
|
235
|
+
if (this.epoch === undefined)
|
|
236
|
+
return;
|
|
237
|
+
this.epoch = { ...this.epoch, tools };
|
|
238
|
+
}
|
|
239
|
+
/** The tools declared to the model for this window, as they stand now. */
|
|
240
|
+
toolSpecs() {
|
|
241
|
+
return this.epoch?.tools ?? [];
|
|
242
|
+
}
|
|
243
|
+
/** The system prompt this window's epoch rendered. */
|
|
244
|
+
systemPrompt() {
|
|
245
|
+
return this.epoch?.prompt.system ?? '';
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Add one model call's tokens to the window's running total, and publish the
|
|
249
|
+
* usage event.
|
|
250
|
+
*
|
|
251
|
+
* The event is what makes a running window current: the host asks every
|
|
252
|
+
* plugin section's revision and re-reads the external tools on it, once per
|
|
253
|
+
* step, so a `prompt_delta` and a `tools_delta` are delivered here rather than
|
|
254
|
+
* never.
|
|
255
|
+
*/
|
|
256
|
+
addUsage(usage) {
|
|
257
|
+
if (usage === undefined)
|
|
258
|
+
return;
|
|
259
|
+
this.usageValue.used += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
|
260
|
+
this.publish({ kind: 'usage', usage: { ...this.usageValue } });
|
|
261
|
+
}
|
|
262
|
+
async end(handoff, reason = 'model_ended') {
|
|
263
|
+
if (this.currentState === 'ended')
|
|
264
|
+
return;
|
|
265
|
+
this.currentState = 'ended';
|
|
266
|
+
const outcome = this.outcomeOf(reason, handoff);
|
|
267
|
+
this.bus.publish({ kind: 'ended', outcome });
|
|
268
|
+
this.bus.close();
|
|
269
|
+
this.ended.resolve(outcome);
|
|
270
|
+
this.onEnd();
|
|
271
|
+
}
|
|
272
|
+
async pause() {
|
|
273
|
+
if (this.currentState === 'ended' || this.currentState === 'paused')
|
|
274
|
+
return;
|
|
275
|
+
this.resumeState = this.currentState;
|
|
276
|
+
this.currentState = 'paused';
|
|
277
|
+
}
|
|
278
|
+
async resume() {
|
|
279
|
+
if (this.currentState !== 'paused')
|
|
280
|
+
return;
|
|
281
|
+
this.currentState = this.resumeState;
|
|
282
|
+
}
|
|
283
|
+
usage() {
|
|
284
|
+
return this.usageValue;
|
|
285
|
+
}
|
|
286
|
+
events() {
|
|
287
|
+
return this.bus.subscribe();
|
|
288
|
+
}
|
|
289
|
+
outcome() {
|
|
290
|
+
return this.ended.promise;
|
|
291
|
+
}
|
|
292
|
+
outcomeOf(reason, handoff) {
|
|
293
|
+
return {
|
|
294
|
+
window: this.window,
|
|
295
|
+
reason,
|
|
296
|
+
...handoff === undefined ? {} : { handoff },
|
|
297
|
+
usage: this.usageValue,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** The DSH-backed arbor agent backend: one per process, one entry per window. */
|
|
302
|
+
export class DshBackend {
|
|
303
|
+
name = 'dsh';
|
|
304
|
+
windows = new Map();
|
|
305
|
+
waiting = new Map();
|
|
306
|
+
headers = new WeakMap();
|
|
307
|
+
options;
|
|
308
|
+
constructor(options) {
|
|
309
|
+
this.options = options;
|
|
310
|
+
}
|
|
311
|
+
/** Resolves once `open` has captured this window's frozen epoch. */
|
|
312
|
+
whenOpen(window) {
|
|
313
|
+
const waiting = this.waiting.get(window);
|
|
314
|
+
if (waiting !== undefined)
|
|
315
|
+
return waiting.promise;
|
|
316
|
+
const entry = this.windows.get(window);
|
|
317
|
+
if (entry !== undefined)
|
|
318
|
+
return Promise.resolve();
|
|
319
|
+
const deferred = Promise.withResolvers();
|
|
320
|
+
this.waiting.set(window, deferred);
|
|
321
|
+
return deferred.promise;
|
|
322
|
+
}
|
|
323
|
+
async models() {
|
|
324
|
+
return [{ id: 'dsh-session', provider: 'dsh', images: true, name: 'DSH session' }];
|
|
325
|
+
}
|
|
326
|
+
async login() {
|
|
327
|
+
return { provider: 'dsh', state: 'logged-in', models: await this.models() };
|
|
328
|
+
}
|
|
329
|
+
externalTools() {
|
|
330
|
+
return dshExternalTools({
|
|
331
|
+
tools: this.options.tools,
|
|
332
|
+
agents: this.options.agents,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The names of the DSH tools this backend hands to arbor, for the
|
|
337
|
+
* `<harness>` section's revision and its tool-usage lines. The same filter
|
|
338
|
+
* {@link externalTools} applies, so a name here is a name the model can call.
|
|
339
|
+
*/
|
|
340
|
+
externalToolNames() {
|
|
341
|
+
return this.externalTools().map(tool => tool.name).sort();
|
|
342
|
+
}
|
|
343
|
+
async open(request) {
|
|
344
|
+
const bus = new WindowEventBus();
|
|
345
|
+
const session = new DshWindowSession(request.window, bus, () => { this.release(request.window); }, delivery => renderDelivery(delivery, this.options.renderer()));
|
|
346
|
+
session.open(request);
|
|
347
|
+
this.windows.set(request.window, { session, agent: undefined });
|
|
348
|
+
this.waiting.get(request.window)?.resolve();
|
|
349
|
+
this.waiting.delete(request.window);
|
|
350
|
+
return session;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Bind the DSH session agent whose turns drive this window. A window a
|
|
354
|
+
* restart continued opens before its session is resumed, so the binding is
|
|
355
|
+
* made when the session comes back, not when the window does.
|
|
356
|
+
*/
|
|
357
|
+
bind(window, agent) {
|
|
358
|
+
const entry = this.windows.get(window);
|
|
359
|
+
if (entry === undefined)
|
|
360
|
+
return;
|
|
361
|
+
entry.agent = agent;
|
|
362
|
+
entry.session.bind(agent);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Drop one window's binding, the way a session's disposal does: the window
|
|
366
|
+
* itself is the host's and lives out arbor's own idle policy.
|
|
367
|
+
*/
|
|
368
|
+
unbind(window) {
|
|
369
|
+
const entry = this.windows.get(window);
|
|
370
|
+
if (entry === undefined)
|
|
371
|
+
return;
|
|
372
|
+
entry.agent = undefined;
|
|
373
|
+
entry.session.unbind();
|
|
374
|
+
}
|
|
375
|
+
/** Drop one ended window's state, so nothing retains its agent or its bus. */
|
|
376
|
+
release(window) {
|
|
377
|
+
this.windows.delete(window);
|
|
378
|
+
this.waiting.get(window)?.resolve();
|
|
379
|
+
this.waiting.delete(window);
|
|
380
|
+
}
|
|
381
|
+
/** The window's live events, observable beside the host's own watch loop. */
|
|
382
|
+
events(window) {
|
|
383
|
+
return this.requireEntry(window).session.events();
|
|
384
|
+
}
|
|
385
|
+
/** The system prompt this window's epoch rendered. */
|
|
386
|
+
systemPrompt(window) {
|
|
387
|
+
return this.requireEntry(window).session.systemPrompt();
|
|
388
|
+
}
|
|
389
|
+
/** The direct tools declared to the model for this window, as they stand now. */
|
|
390
|
+
toolSpecs(window) {
|
|
391
|
+
return this.requireEntry(window).session.toolSpecs();
|
|
392
|
+
}
|
|
393
|
+
/** Publish one live stream event to the window. */
|
|
394
|
+
publish(window, event) {
|
|
395
|
+
this.windows.get(window)?.session.publish(event);
|
|
396
|
+
}
|
|
397
|
+
/** The turn's work settled: the window's run ends and it stays open for more. */
|
|
398
|
+
finishRun(window) {
|
|
399
|
+
const session = this.windows.get(window)?.session;
|
|
400
|
+
if (session === undefined || session.state === 'ended')
|
|
401
|
+
return;
|
|
402
|
+
// The run is over before its end is published, so the next delivery this
|
|
403
|
+
// window takes is the next turn's input rather than this run's.
|
|
404
|
+
session.finishRun();
|
|
405
|
+
session.publish({
|
|
406
|
+
kind: 'run_ended',
|
|
407
|
+
outcome: { window, reason: 'model_ended', usage: session.usage() },
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/** End the window, the way a host shutdown does. */
|
|
411
|
+
async endWindow(window, reason = 'host_restart') {
|
|
412
|
+
await this.windows.get(window)?.session.end(undefined, reason);
|
|
413
|
+
}
|
|
414
|
+
requireEntry(window) {
|
|
415
|
+
const entry = this.windows.get(window);
|
|
416
|
+
if (entry === undefined) {
|
|
417
|
+
throw new Error(`arbor backend: no open window "${window}"`);
|
|
418
|
+
}
|
|
419
|
+
return entry;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* One model call for the DSH-side agent's current step: resolve the route,
|
|
423
|
+
* log the request header, and stream through `ctx.llm`. The request's
|
|
424
|
+
* messages come from the session log, exactly like the official loop's
|
|
425
|
+
* `buildRequest`.
|
|
426
|
+
*/
|
|
427
|
+
async modelCall(input) {
|
|
428
|
+
const { agent } = input;
|
|
429
|
+
const llm = this.options.llm;
|
|
430
|
+
const { signal } = input;
|
|
431
|
+
// A model call is this backend session's turn at work: its state is
|
|
432
|
+
// running from here, before the first streamed event the host sees.
|
|
433
|
+
this.requireEntry(input.window).session.beginRun();
|
|
434
|
+
const proposed = {
|
|
435
|
+
provider: input.selection.provider,
|
|
436
|
+
model: input.selection.model,
|
|
437
|
+
...input.selection.reasoningEffort === undefined ? {} : { reasoningEffort: input.selection.reasoningEffort },
|
|
438
|
+
...input.maxTokens === undefined ? {} : { maxTokens: input.maxTokens },
|
|
439
|
+
};
|
|
440
|
+
const prepared = await llm.prepareCall(proposed, signal);
|
|
441
|
+
signal.throwIfAborted();
|
|
442
|
+
const config = prepared.config;
|
|
443
|
+
const tools = this.toolSpecs(input.window).map(schemaOf);
|
|
444
|
+
this.logRequestHeader(agent, config, tools, input.position);
|
|
445
|
+
const messages = agent.session.deriveMessages();
|
|
446
|
+
const request = markAgentLoopRequest({
|
|
447
|
+
...config,
|
|
448
|
+
messages,
|
|
449
|
+
toolHistory: agent.session.toolHistory(),
|
|
450
|
+
...tools.length > 0 ? { tools: tools } : {},
|
|
451
|
+
sessionId: agent.session.id,
|
|
452
|
+
signal,
|
|
453
|
+
});
|
|
454
|
+
const assembler = new BlockAssembler();
|
|
455
|
+
const stream = prepared.stream(request);
|
|
456
|
+
for await (const chunk of stream) {
|
|
457
|
+
signal.throwIfAborted();
|
|
458
|
+
input.onChunk(chunk);
|
|
459
|
+
assembler.push(chunk);
|
|
460
|
+
}
|
|
461
|
+
signal.throwIfAborted();
|
|
462
|
+
// The window's token total moves, which is also the host's cue to ask every
|
|
463
|
+
// plugin section's revision and re-read the external tools: a prompt delta
|
|
464
|
+
// and a tools delta are delivered on this event.
|
|
465
|
+
this.windows.get(input.window)?.session.addUsage(assembler.usage);
|
|
466
|
+
return { ...assembler.usage === undefined ? {} : { usage: assembler.usage }, finish: assembler.finish.kind };
|
|
467
|
+
}
|
|
468
|
+
/** Log the resolved request header the way `ReactLoopAgent.buildRequest` does. */
|
|
469
|
+
logRequestHeader(agent, config, tools, position) {
|
|
470
|
+
const header = canonicalHeader({
|
|
471
|
+
config,
|
|
472
|
+
...tools.length > 0 ? { tools: tools } : {},
|
|
473
|
+
});
|
|
474
|
+
const baseline = agent.session.requestHeader();
|
|
475
|
+
const log = this.headers.get(agent);
|
|
476
|
+
let headerSeq;
|
|
477
|
+
if (log === undefined || !log.logged) {
|
|
478
|
+
headerSeq = agent.session.append('request/header', {
|
|
479
|
+
header,
|
|
480
|
+
reason: baseline === undefined ? 'initial' : 'resume',
|
|
481
|
+
}).seq;
|
|
482
|
+
this.headers.set(agent, { logged: true, toolNames: tools.map(tool => String(tool['name'])) });
|
|
483
|
+
}
|
|
484
|
+
else if (baseline === undefined || !headerEquals(baseline, header)) {
|
|
485
|
+
headerSeq = agent.session.append('request/header', { header, reason: 'change' }).seq;
|
|
486
|
+
// The tool set is diffed against the previous header rather than against
|
|
487
|
+
// this process's own record of it, so a window a restart continued reports
|
|
488
|
+
// the same additions the log already implies. Without that, the second
|
|
489
|
+
// process's first change would re-report tools the first one added.
|
|
490
|
+
const previousNames = new Set((baseline?.tools ?? []).map(tool => String(tool.name)));
|
|
491
|
+
const currentNames = new Set(tools.map(tool => String(tool['name'])));
|
|
492
|
+
const additions = [...currentNames].filter(name => !previousNames.has(name))
|
|
493
|
+
.map(name => ({ type: 'tool-addition', toolName: name }));
|
|
494
|
+
const removals = [...previousNames].filter(name => !currentNames.has(name))
|
|
495
|
+
.map(name => ({ type: 'tool-removal', toolName: name }));
|
|
496
|
+
if (additions.length > 0 || removals.length > 0) {
|
|
497
|
+
// A `developer/message` that activates a tool must name the header that
|
|
498
|
+
// declares it, which is the one just appended: DSH's own append refuses
|
|
499
|
+
// the event otherwise, and the refusal lands on the turn that asked for
|
|
500
|
+
// the model call.
|
|
501
|
+
agent.session.append('developer/message', {
|
|
502
|
+
...position,
|
|
503
|
+
message: createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [...additions, ...removals] }),
|
|
504
|
+
...additions.length > 0 ? { headerSeq } : {},
|
|
505
|
+
}, { surfaceOp: 'append' });
|
|
506
|
+
}
|
|
507
|
+
this.headers.set(agent, { logged: true, toolNames: tools.map(tool => String(tool['name'])) });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/** Create the DSH-backed arbor agent backend. */
|
|
512
|
+
export function createDshBackend(options) {
|
|
513
|
+
return new DshBackend(options);
|
|
514
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4fu/dsh-arbor-llm",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "2026.1110.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "arbor AgentBackend that runs each window's turns through DSH's ctx.llm and ctx.tools",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"default": "./index.js"
|
|
10
|
+
},
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@4fu/arbor-interfaces": "2026.1109.1",
|
|
15
|
+
"@4fu/arbor-prompt": "2026.1109.1",
|
|
16
|
+
"@4fu/dsh-arbor-tools": "2026.1110.0",
|
|
17
|
+
"@deepseek-ai/cordis": "4.1.0",
|
|
18
|
+
"@deepseek-ai/dsh-agent": "0.2.2-rc.1",
|
|
19
|
+
"@deepseek-ai/dsh-llm": "0.2.2-rc.1",
|
|
20
|
+
"@deepseek-ai/dsh-session": "0.2.2-rc.1",
|
|
21
|
+
"@deepseek-ai/dsh-tools": "0.2.2-rc.1"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/4fuu/dsh-arbor.git",
|
|
27
|
+
"directory": "packages/arbor-llm"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=24.9.0",
|
|
31
|
+
"dsh": "0.2.2-rc.1"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"index.d.ts",
|
|
38
|
+
"index.js"
|
|
39
|
+
],
|
|
40
|
+
"types": "./index.d.ts",
|
|
41
|
+
"main": "./index.js"
|
|
42
|
+
}
|