@4fu/dsh-arbor-driver 0.0.0-stage → 2026.1110.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +249 -2
- package/assistant-stream.d.ts +47 -0
- package/assistant-stream.js +98 -0
- package/driver.d.ts +212 -0
- package/driver.js +885 -0
- package/inbox.d.ts +55 -0
- package/inbox.js +160 -0
- package/index.d.ts +18 -0
- package/index.js +14 -0
- package/package.json +76 -4
- package/registry.d.ts +57 -0
- package/registry.js +95 -0
- package/relay.d.ts +51 -0
- package/relay.js +144 -0
- package/runtime-context.d.ts +49 -0
- package/runtime-context.js +86 -0
- package/session-agent.d.ts +142 -0
- package/session-agent.js +467 -0
package/driver.js
ADDED
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arbor driver: the AgentFactory that runs arbor-preset sessions, and the
|
|
3
|
+
* Cordis plugin row that mounts it.
|
|
4
|
+
*
|
|
5
|
+
* One DSH user session is one conversation target of the process's single Arbor
|
|
6
|
+
* host (`@4fu/dsh-arbor-host`): the journal, the note store and the board are
|
|
7
|
+
* shared by every session, and each session is one source of that one host.
|
|
8
|
+
* Arbor's own model is what a session's windows follow — a window ends when its
|
|
9
|
+
* run ends and the session's next turn opens the next window of its chain, so
|
|
10
|
+
* several windows serve one session and none of them is the session. Per
|
|
11
|
+
* session the driver owns
|
|
12
|
+
*
|
|
13
|
+
* - the window, opened through `host.dispatch` on the session's first turn and
|
|
14
|
+
* taken back — not reopened — when the session already has a live one, which
|
|
15
|
+
* is what a restart's continuation is;
|
|
16
|
+
* - the bridge the session agent drives: `ensureWindow`, `modelCall`,
|
|
17
|
+
* `runToolCall` (arbor's tool path into `ctx.tools.execute`), `publish`,
|
|
18
|
+
* `finishRun`, `close`; and
|
|
19
|
+
* - the window's DSH agent registration, which keeps it out of the session
|
|
20
|
+
* list (`meta.origin: 'subagent'`, owned by the session's agent).
|
|
21
|
+
*
|
|
22
|
+
* `resume` loads the persisted session, replays onto its log what the arbor
|
|
23
|
+
* journal recorded and the log lost, and binds the session back to the window
|
|
24
|
+
* the journal says is its own — the window the host continued at boot.
|
|
25
|
+
*
|
|
26
|
+
* The creation lifecycle mirrors `AgentLoop.createAgent`
|
|
27
|
+
* (packages/core/agent-loop/src/index.ts): prepare the session, take
|
|
28
|
+
* persistence ownership when a backend is mounted, run setup while the agent
|
|
29
|
+
* is unpublished, then enter and announce the session and the agent through
|
|
30
|
+
* the same registries the official loop uses.
|
|
31
|
+
*
|
|
32
|
+
* B1 scope, per the plan: compaction, goals, agentTeams, retry and fork are
|
|
33
|
+
* off for arbor sessions.
|
|
34
|
+
*/
|
|
35
|
+
import { randomUUID } from 'node:crypto';
|
|
36
|
+
import { getTraceable, Service, symbols } from '@deepseek-ai/cordis';
|
|
37
|
+
import z from '@deepseek-ai/schemastery';
|
|
38
|
+
import { modelSelectionProjectionDefinition } from '@deepseek-ai/dsh-agent';
|
|
39
|
+
import { brandString } from '@deepseek-ai/dsh-brand';
|
|
40
|
+
import { SessionPreparation, interruptedTurnClosers } from '@deepseek-ai/dsh-session';
|
|
41
|
+
import { createToolResultMessage } from '@deepseek-ai/dsh-llm';
|
|
42
|
+
import { sessionTarget } from '@4fu/dsh-arbor-host';
|
|
43
|
+
import { replacedToolsRegistered } from '@4fu/dsh-arbor-tools';
|
|
44
|
+
import { dataBlock } from '@4fu/arbor-prompt';
|
|
45
|
+
import { ARBOR_PRESET, ArborAgentRegistry } from "./registry.js";
|
|
46
|
+
import { ArborSessionAgent } from "./session-agent.js";
|
|
47
|
+
import { inboxProjectionDefinition } from "./inbox.js";
|
|
48
|
+
import { relayToolCalls, windowToolCalls } from "./relay.js";
|
|
49
|
+
/** One session's arbor world: its window, its bridge, its window agent. */
|
|
50
|
+
class SessionArbor {
|
|
51
|
+
window;
|
|
52
|
+
windowAgent;
|
|
53
|
+
bridgeCache;
|
|
54
|
+
/** The session's write handle onto its durable log, once persistence is mounted. */
|
|
55
|
+
stored;
|
|
56
|
+
agent;
|
|
57
|
+
/** The create/resume options a re-route replays, so a session keeps its own. */
|
|
58
|
+
agentOptions;
|
|
59
|
+
agentSetup;
|
|
60
|
+
driver;
|
|
61
|
+
constructor(driver, agent, agentOptions, agentSetup) {
|
|
62
|
+
this.driver = driver;
|
|
63
|
+
this.agent = agent;
|
|
64
|
+
this.agentOptions = agentOptions;
|
|
65
|
+
this.agentSetup = agentSetup;
|
|
66
|
+
}
|
|
67
|
+
/** Hand this world the session's write handle, once it has one. */
|
|
68
|
+
keepStored(stored) {
|
|
69
|
+
this.stored = stored;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* End this session's arbor world: unbind its window, unwind the agent's own
|
|
73
|
+
* scope — which detaches the agent and its session and closes the bridge — and
|
|
74
|
+
* release the session's write handle so another driver can open the log.
|
|
75
|
+
*
|
|
76
|
+
* This is the teardown a re-route needs in either direction, and it is the
|
|
77
|
+
* mirror of the embedded loop's own `AgentHandle.dispose`: the window itself is
|
|
78
|
+
* the host's and lives out arbor's own idle policy, so only the binding goes.
|
|
79
|
+
*/
|
|
80
|
+
async dispose() {
|
|
81
|
+
await this.agent.scope.dispose();
|
|
82
|
+
await this.stored?.handle.close().catch(() => { });
|
|
83
|
+
this.stored = undefined;
|
|
84
|
+
}
|
|
85
|
+
/** The one Arbor host of this process, and the backend every window runs through. */
|
|
86
|
+
get hostService() {
|
|
87
|
+
return this.driver.hostService;
|
|
88
|
+
}
|
|
89
|
+
/** The window this session's work runs in, once it has one. */
|
|
90
|
+
get windowRef() {
|
|
91
|
+
return this.window;
|
|
92
|
+
}
|
|
93
|
+
/** Whether the window this session holds is still the host's. */
|
|
94
|
+
isLive(window) {
|
|
95
|
+
if (window === undefined)
|
|
96
|
+
return false;
|
|
97
|
+
return this.hostService.host.live().some(record => record.window === window && record.state !== 'ended');
|
|
98
|
+
}
|
|
99
|
+
/** Open the window on first use, through arbor's dispatch. */
|
|
100
|
+
async ensureWindow(request) {
|
|
101
|
+
const held = this.window;
|
|
102
|
+
if (held !== undefined && this.isLive(held))
|
|
103
|
+
return held;
|
|
104
|
+
// A window this session already has is taken back, not answered by a second
|
|
105
|
+
// one: a restart continues the session's window under a new ref, and an
|
|
106
|
+
// earlier turn's window is still open while it waits out its idle period.
|
|
107
|
+
const existing = await this.hostService.windowFor(this.agent.session.id);
|
|
108
|
+
const window = existing ?? await this.dispatchWindow(request);
|
|
109
|
+
this.window = window;
|
|
110
|
+
this.hostService.backend.bind(window, this.agent);
|
|
111
|
+
await this.hostService.backend.whenOpen(window);
|
|
112
|
+
await this.registerWindowAgent(window);
|
|
113
|
+
return window;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The user's request, as the window's first board line.
|
|
117
|
+
*
|
|
118
|
+
* A window opens before the turn's input reaches the model, so the board's
|
|
119
|
+
* first title cannot come from the task alone. The user message is already
|
|
120
|
+
* on the session log when the task dispatches; use its text, one line and
|
|
121
|
+
* bounded, instead of the driver's internal placeholder. A window with no
|
|
122
|
+
* user text yet falls back to the same neutral description.
|
|
123
|
+
*/
|
|
124
|
+
requestText() {
|
|
125
|
+
for (const event of this.agent.session.snapshotEvents().toReversed()) {
|
|
126
|
+
if (event.type !== 'user/message')
|
|
127
|
+
continue;
|
|
128
|
+
const content = event.data.content ?? [];
|
|
129
|
+
const text = content
|
|
130
|
+
.filter(block => block.type === 'text')
|
|
131
|
+
.map(block => block.text ?? '')
|
|
132
|
+
.join(' ')
|
|
133
|
+
.replace(/\s+/g, ' ')
|
|
134
|
+
.trim();
|
|
135
|
+
if (text !== '')
|
|
136
|
+
return text.length > 120 ? `${text.slice(0, 119)}…` : text;
|
|
137
|
+
}
|
|
138
|
+
return 'DeepSeek Harness session turn';
|
|
139
|
+
}
|
|
140
|
+
/** Dispatch this session's task, which opens the window that takes it. */
|
|
141
|
+
async dispatchWindow(request) {
|
|
142
|
+
const session = this.agent.session;
|
|
143
|
+
const reason = request === undefined ? this.requestText() : request.replace(/\s+/g, ' ').trim();
|
|
144
|
+
const task = {
|
|
145
|
+
kind: 'batch',
|
|
146
|
+
reason: reason.length > 120 ? `${reason.slice(0, 119)}…` : reason,
|
|
147
|
+
targets: [sessionTarget(session.id)],
|
|
148
|
+
// The label is what the host carries onto every window this task opens,
|
|
149
|
+
// its continuations and the journal's record of both. A read-only surface
|
|
150
|
+
// finds a session's windows by it, with no map of its own.
|
|
151
|
+
label: session.id,
|
|
152
|
+
time: Date.now(),
|
|
153
|
+
content: [dataBlock(`dsh session ${session.id}`)],
|
|
154
|
+
explicit: true,
|
|
155
|
+
source: 'dsh',
|
|
156
|
+
};
|
|
157
|
+
const result = await this.hostService.host.dispatch(task);
|
|
158
|
+
if (result.window === null) {
|
|
159
|
+
throw new Error(`arbor driver: dispatch opened no window for session "${session.id}" (route ${result.route})`);
|
|
160
|
+
}
|
|
161
|
+
return result.window;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Register the window as a DSH agent with `meta.origin: 'subagent'`, the
|
|
165
|
+
* convention `packages/subagent/subagent/src/child-agent.ts` uses for
|
|
166
|
+
* delegated children: the registry and the agent-team tools see the window,
|
|
167
|
+
* and the sidebar's `sessionVisible`
|
|
168
|
+
* (packages/client/ui-workspace/src/client/tree.ts) leaves it out. The agent
|
|
169
|
+
* is owned by this session's agent, so it is not a registry root.
|
|
170
|
+
*/
|
|
171
|
+
async registerWindowAgent(window) {
|
|
172
|
+
if (this.windowAgent !== undefined)
|
|
173
|
+
return;
|
|
174
|
+
const sessionId = brandString(`window-${window}-${randomUUID()}`);
|
|
175
|
+
const { agent } = await this.driver.driverContext.agents.create({
|
|
176
|
+
sessionId,
|
|
177
|
+
parentAgent: this.agent,
|
|
178
|
+
meta: {
|
|
179
|
+
origin: 'subagent',
|
|
180
|
+
parentSession: this.agent.session.id,
|
|
181
|
+
// The window works in the arbor shared folder, so its hidden agent's cwd
|
|
182
|
+
// is that folder rather than the DSH process's own directory.
|
|
183
|
+
cwd: this.hostService.sharedFolder,
|
|
184
|
+
},
|
|
185
|
+
agentOptions: {},
|
|
186
|
+
});
|
|
187
|
+
this.windowAgent = agent;
|
|
188
|
+
}
|
|
189
|
+
/** The agent registered for this session's window, once one exists. */
|
|
190
|
+
get windowAgentOf() {
|
|
191
|
+
return this.windowAgent;
|
|
192
|
+
}
|
|
193
|
+
/** The bridge the session agent drives. */
|
|
194
|
+
bridge() {
|
|
195
|
+
this.bridgeCache ??= {
|
|
196
|
+
ensureWindow: request => this.ensureWindow(request),
|
|
197
|
+
systemPrompt: window => this.hostService.backend.systemPrompt(window),
|
|
198
|
+
modelCall: input => this.hostService.backend.modelCall({ ...input, agent: this.agent }),
|
|
199
|
+
runToolCall: async (call, position) => {
|
|
200
|
+
const window = await this.ensureWindow();
|
|
201
|
+
const control = this.hostService.host.windowControl();
|
|
202
|
+
const args = safeArgs(call.arguments);
|
|
203
|
+
const at = await control.recordToolCall(window, { name: call.name, args, callId: call.id });
|
|
204
|
+
// The call is mirrored onto the session log here, for every tool the
|
|
205
|
+
// window runs — arbor's own and DSH's alike. The log is the history the
|
|
206
|
+
// next model request is derived from, and a provider refuses a history
|
|
207
|
+
// whose assistant message asks for a tool call that has no result, so a
|
|
208
|
+
// window that runs only arbor's own tools still leaves a valid log.
|
|
209
|
+
// DSH's pipeline is not asked to log (the external tools carry no log
|
|
210
|
+
// position), which is what keeps this the one pair per call.
|
|
211
|
+
const callSeq = this.agent.session.append('tool/call', {
|
|
212
|
+
...position,
|
|
213
|
+
callId: call.id,
|
|
214
|
+
name: call.name,
|
|
215
|
+
arguments: call.arguments,
|
|
216
|
+
}).seq;
|
|
217
|
+
const result = await control.runToolCall(window, { name: call.name, args, callId: call.id }, at ?? undefined);
|
|
218
|
+
this.agent.session.append('tool/result', {
|
|
219
|
+
...position,
|
|
220
|
+
message: createToolResultMessage({
|
|
221
|
+
callId: call.id,
|
|
222
|
+
// Arbor 2026.1107.1 fills a refusal's wording into `content` as well
|
|
223
|
+
// as into `error` — the rule, the message and the next action, in the
|
|
224
|
+
// one form the journal records a refusal with — so the result is
|
|
225
|
+
// written through as it stands. DSH's own tools put a denial's
|
|
226
|
+
// reason in the content too, and without this the model would read
|
|
227
|
+
// an empty tool result and never learn why a call was refused.
|
|
228
|
+
content: [{ type: 'text', text: result.content }],
|
|
229
|
+
isError: !result.ok,
|
|
230
|
+
}),
|
|
231
|
+
...result.ok ? {} : { error: toolErrorOf(result) },
|
|
232
|
+
}, { surfaceOp: 'append', sourceEventSeqs: [callSeq] });
|
|
233
|
+
return result;
|
|
234
|
+
},
|
|
235
|
+
publish: (window, event) => { this.hostService.backend.publish(window, event); },
|
|
236
|
+
flushSends: () => { this.hostService.flushSends(); },
|
|
237
|
+
finishRun: window => { this.hostService.backend.finishRun(window); },
|
|
238
|
+
close: async () => {
|
|
239
|
+
// The window is the host's, not this session's: it stays open for the
|
|
240
|
+
// session's next turn and ends on arbor's own idle policy, exactly like
|
|
241
|
+
// a window whose model went quiet. Only the binding is dropped.
|
|
242
|
+
if (this.window !== undefined)
|
|
243
|
+
this.hostService.backend.unbind(this.window);
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
return this.bridgeCache;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** The arbor agent factory and Cordis plugin. */
|
|
250
|
+
export class ArborDriver extends Service {
|
|
251
|
+
static inject = ['agents', 'sessions', 'sessionProjections', 'arborHost', 'tools', 'systemPrompt'];
|
|
252
|
+
static Config = z.object({});
|
|
253
|
+
config;
|
|
254
|
+
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
|
|
255
|
+
runtime;
|
|
256
|
+
sessions = new Map();
|
|
257
|
+
/**
|
|
258
|
+
* Sessions the embedded AgentLoop is running, with the handle its creation
|
|
259
|
+
* returned. The driver created every one of them — the registry routes every
|
|
260
|
+
* session through this factory — so it holds the only thing that can end one:
|
|
261
|
+
* that is what lets a preset selection re-route a session that has not started
|
|
262
|
+
* a turn. An ordinary session is never touched unless its user picks arbor.
|
|
263
|
+
*/
|
|
264
|
+
embedded = new Map();
|
|
265
|
+
modelProjectionHandle;
|
|
266
|
+
inboxProjectionHandle;
|
|
267
|
+
constructor(ctx, config = {}) {
|
|
268
|
+
super(ctx, 'arborDriver');
|
|
269
|
+
this.config = config;
|
|
270
|
+
this.runtime = { ctx };
|
|
271
|
+
}
|
|
272
|
+
async [Service.init]() {
|
|
273
|
+
const ctx = this.runtime.ctx;
|
|
274
|
+
// Refcounted alongside the embedded AgentLoop's own registrations of the
|
|
275
|
+
// same keys (SessionProjectionRegistry.register shares one definition per
|
|
276
|
+
// key), so an arbor agent reads its projections with or without the
|
|
277
|
+
// official loop mounted.
|
|
278
|
+
this.modelProjectionHandle = await ctx.sessionProjections.register(modelSelectionProjectionDefinition);
|
|
279
|
+
this.inboxProjectionHandle = await ctx.sessionProjections.register(inboxProjectionDefinition);
|
|
280
|
+
ctx.effect(() => ctx.agents.useArborFactory(this), 'arborDriver.useArborFactory()');
|
|
281
|
+
// The seam that makes picking Arbor in a composer actually run arbor. DSH's
|
|
282
|
+
// own preset registry commits a blank session's pick by appending
|
|
283
|
+
// `agent-preset/selected` and re-mounting the session's composition, which is
|
|
284
|
+
// all a preset whose presets share one driver needs; a preset that changes the
|
|
285
|
+
// driver has to act on the same event. The log event is observed rather than
|
|
286
|
+
// the registry's own `agent-preset/selected` emit, because that one is
|
|
287
|
+
// dispatched on the registry's context and reaches nobody outside it, while
|
|
288
|
+
// `session/event` is the session-scoped observer channel every host row can
|
|
289
|
+
// listen on. A session that has started a turn never gets here — the registry
|
|
290
|
+
// refuses that selection before it is committed — so the only case is a
|
|
291
|
+
// session still waiting for its first turn, which is exactly the hero.
|
|
292
|
+
// The event carries the OTHER direction too, and it has to be honoured for a
|
|
293
|
+
// session that SELECTED arbor: DSH's session controller re-selects the
|
|
294
|
+
// default preset whenever a blank session is reused (`reuseLive` calls
|
|
295
|
+
// `presets.select` with the resolved default, and `reuseSetup` appends the
|
|
296
|
+
// event itself), so a session the user opened in another workspace is
|
|
297
|
+
// relabelled `standard` while it still runs arbor. Following the event back
|
|
298
|
+
// is what keeps the label and the driver saying the same thing. A session
|
|
299
|
+
// CREATED under arbor is the exception, and the gate is in
|
|
300
|
+
// {@link rerouteToEmbedded}: that session is never switched in place by any
|
|
301
|
+
// surface that knows what the user clicked, so the only selection that can
|
|
302
|
+
// reach it is the controller's default.
|
|
303
|
+
ctx.on('session/event', (session, event) => {
|
|
304
|
+
if (event.type !== 'agent-preset/selected')
|
|
305
|
+
return;
|
|
306
|
+
const selected = event.data.agentPreset;
|
|
307
|
+
if (selected === ARBOR_PRESET) {
|
|
308
|
+
void this.rerouteToArbor(session.id).catch((error) => {
|
|
309
|
+
this.runtime.ctx.logger.warn(`arbor driver: re-routing session ${session.id} to arbor failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
void this.rerouteToEmbedded(session.id, typeof selected === 'string' ? selected : undefined).catch((error) => {
|
|
314
|
+
this.runtime.ctx.logger.warn(`arbor driver: re-routing session ${session.id} off arbor failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Re-route one session from the embedded AgentLoop to arbor.
|
|
320
|
+
*
|
|
321
|
+
* The loop's agent is ended with the handle its creation returned — which
|
|
322
|
+
* unregisters the agent and detaches the session from the store, leaving the
|
|
323
|
+
* durable log exactly as it stands — and the session is then opened again
|
|
324
|
+
* under arbor, the way a restart opens it. The session keeps its identity, its
|
|
325
|
+
* log and every event it already committed, so the composer that is bound to
|
|
326
|
+
* it keeps working; what changes is which driver its next turn runs.
|
|
327
|
+
*/
|
|
328
|
+
async rerouteToArbor(sessionId) {
|
|
329
|
+
const hold = this.embedded.get(sessionId);
|
|
330
|
+
if (hold === undefined)
|
|
331
|
+
return;
|
|
332
|
+
this.embedded.delete(sessionId);
|
|
333
|
+
// A session that has started a turn keeps the driver it began with. DSH's
|
|
334
|
+
// preset registry refuses such a selection before it is committed, so this
|
|
335
|
+
// is a guard rather than a path; it is here because the alternative —
|
|
336
|
+
// silently keeping the old driver — is the one thing the owner ruled out.
|
|
337
|
+
const boundary = this.runtime.ctx.agents.turnBoundaryOf(hold.handle.agent.session);
|
|
338
|
+
if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) {
|
|
339
|
+
this.runtime.ctx.logger.warn(`arbor driver: session ${sessionId} selected arbor after its first turn and keeps the embedded loop`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// The re-routed session keeps the cwd it was created with — the directory the
|
|
343
|
+
// user picked for it in DSH's own project picker — and arbor works in the
|
|
344
|
+
// shared folder, so the picked directory is what the pointer names. It is
|
|
345
|
+
// read before the embedded agent is ended, and recorded once, at the moment
|
|
346
|
+
// the session becomes an arbor session: that is the ask, and it is not
|
|
347
|
+
// repeated on a later resume.
|
|
348
|
+
const requested = hold.handle.agent.session.header.cwd;
|
|
349
|
+
const cwd = await this.hostService.resolveArborCwd(requested);
|
|
350
|
+
await hold.handle.dispose();
|
|
351
|
+
await this.resumeArbor(this.runtime.ctx, {
|
|
352
|
+
resumeSessionId: sessionId,
|
|
353
|
+
agentOptions: hold.agentOptions,
|
|
354
|
+
setup: hold.setup,
|
|
355
|
+
cwd,
|
|
356
|
+
});
|
|
357
|
+
this.noteExternalProject(requested, sessionId);
|
|
358
|
+
// The session kept its identity and its log, but its cwd is now arbor's own
|
|
359
|
+
// directory, so it moves to the workspace that owns that directory: DSH
|
|
360
|
+
// filters a workspace's members by the session's stored cwd, and a session
|
|
361
|
+
// left in the workspace it was created in reads as a member of nowhere —
|
|
362
|
+
// which is what leaves the hero composer with no workspace to name.
|
|
363
|
+
await this.hostService.attachArborWorkspace(sessionId, cwd);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Record a directory an arbor session named as an external pointer for that
|
|
367
|
+
* session, when the user had added the directory through DSH's own
|
|
368
|
+
* add-workspace action.
|
|
369
|
+
*
|
|
370
|
+
* The host answers whether the directory was added, because that is a fact
|
|
371
|
+
* about the project list rather than about this session; a directory that was
|
|
372
|
+
* never added — a cwd the session inherited — is left alone, which is the
|
|
373
|
+
* mistake the earlier resolve-time recording made.
|
|
374
|
+
*/
|
|
375
|
+
noteExternalProject(requested, sessionId) {
|
|
376
|
+
if (requested === undefined)
|
|
377
|
+
return;
|
|
378
|
+
try {
|
|
379
|
+
if (this.hostService.isAddedArborDirectory(requested)) {
|
|
380
|
+
this.hostService.pointerArborProject(requested, sessionId);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
this.runtime.ctx.logger.warn(`arbor driver: recording an external pointer for session ${sessionId} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Re-route one session from arbor back to the embedded AgentLoop.
|
|
389
|
+
*
|
|
390
|
+
* The mirror of {@link rerouteToArbor}, and it exists for one case only: a
|
|
391
|
+
* session that SELECTED arbor while it was blank can select something else
|
|
392
|
+
* again while it is still blank, and the label and the driver under it must
|
|
393
|
+
* say the same thing. That is the SDK or remote `agent-preset/select` path —
|
|
394
|
+
* every surface that knows what the user clicked creates the session under
|
|
395
|
+
* the picked preset instead (the hero mint, the adopt at create), so nothing
|
|
396
|
+
* in the web profile switches a blank in place. The arbor agent is ended with
|
|
397
|
+
* the handle this driver holds, the window keeps running out arbor's own idle
|
|
398
|
+
* policy, and the session is opened again on the embedded loop with the same
|
|
399
|
+
* id, the same log and the same cwd it was created with.
|
|
400
|
+
*
|
|
401
|
+
* **A session created under arbor is never moved off it here.** Its creation
|
|
402
|
+
* header says `agentPreset: 'arbor'`, which is a frozen creation fact
|
|
403
|
+
* (`@deepseek-ai/dsh-agent-preset-registry`, `lib/types/session.js`), and the
|
|
404
|
+
* one selection that can still arrive on it is DSH's own: the session
|
|
405
|
+
* controller re-selects the resolved deployment default whenever a blank is
|
|
406
|
+
* reused with no preset named — `reuseLive` calls `presets.select` with
|
|
407
|
+
* `composition.agentPreset` and `reuseSetup` appends the event itself
|
|
408
|
+
* (`@deepseek-ai/dsh-api-session-controller`, `lib/index.js`). The log cannot
|
|
409
|
+
* tell that re-select from a user's pick, because `presets.select` is the
|
|
410
|
+
* single writer of `agent-preset/selected` (`lib/index.js`), so the
|
|
411
|
+
* attribution has to come from the header instead: a session created as arbor
|
|
412
|
+
* keeps arbor, and its label is put back through the registry's own select so
|
|
413
|
+
* the projection and the driver agree ({@link reassertArbor}). The client side
|
|
414
|
+
* of the same guarantee is that the restore names the recorded preset, so the
|
|
415
|
+
* re-select is never written in the first place — this gate is what stands
|
|
416
|
+
* behind it.
|
|
417
|
+
*
|
|
418
|
+
* A session that has started a turn keeps the driver it began with: DSH refuses
|
|
419
|
+
* that selection before it is committed, so this is the same defensive guard the
|
|
420
|
+
* arbor direction carries, and the alternative — silently keeping the old driver
|
|
421
|
+
* — is the one outcome the owner ruled out.
|
|
422
|
+
*/
|
|
423
|
+
async rerouteToEmbedded(sessionId, selected) {
|
|
424
|
+
const arbor = this.sessions.get(sessionId);
|
|
425
|
+
if (arbor === undefined)
|
|
426
|
+
return;
|
|
427
|
+
if (arbor.agent.session.header.agentPreset === ARBOR_PRESET) {
|
|
428
|
+
this.runtime.ctx.logger.warn(`arbor driver: session ${sessionId} was created under arbor and keeps it; `
|
|
429
|
+
+ `a selection of ${selected ?? '(unnamed)'} is not followed off a creation header`);
|
|
430
|
+
await this.reassertArbor(arbor);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
this.sessions.delete(sessionId);
|
|
434
|
+
const boundary = this.runtime.ctx.agents.turnBoundaryOf(arbor.agent.session);
|
|
435
|
+
if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) {
|
|
436
|
+
this.runtime.ctx.logger.warn(`arbor driver: session ${sessionId} selected another preset after its first turn and keeps arbor`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const options = {
|
|
440
|
+
resumeSessionId: sessionId,
|
|
441
|
+
agentOptions: arbor.agentOptions,
|
|
442
|
+
setup: arbor.agentSetup,
|
|
443
|
+
};
|
|
444
|
+
await arbor.dispose();
|
|
445
|
+
await this.resumeEmbedded(this.runtime.ctx, options);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Put a session's label back on arbor, through the registry's own select.
|
|
449
|
+
*
|
|
450
|
+
* The selection being refused has already been appended to the log, so the
|
|
451
|
+
* session's `agentPreset` projection now names another preset while this
|
|
452
|
+
* driver still runs arbor — a label that says one thing while the driver does
|
|
453
|
+
* another, which is the untruth the gate exists to prevent. `presets.select`
|
|
454
|
+
* is the single writer of that event (`@deepseek-ai/dsh-agent-preset-registry`,
|
|
455
|
+
* `lib/index.js`), and it appends only when the projection does not already
|
|
456
|
+
* name the preset, so this writes one honest event and nothing else: the
|
|
457
|
+
* registry's `bind` is a no-op when the agent's context already holds the
|
|
458
|
+
* preset's generation. The event reaches this driver's own observer, which
|
|
459
|
+
* finds the session already running arbor and does nothing.
|
|
460
|
+
*/
|
|
461
|
+
async reassertArbor(arbor) {
|
|
462
|
+
const presets = this.runtime.ctx.get('agentPresets');
|
|
463
|
+
if (presets === undefined)
|
|
464
|
+
return;
|
|
465
|
+
try {
|
|
466
|
+
await presets.select(arbor.agent, ARBOR_PRESET);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
this.runtime.ctx.logger.warn(`arbor driver: re-asserting arbor on session ${arbor.agent.session.id} failed: `
|
|
470
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** The driver's own DSH context, for the per-session arbor worlds. */
|
|
474
|
+
get driverContext() {
|
|
475
|
+
return this.runtime.ctx;
|
|
476
|
+
}
|
|
477
|
+
/** The one Arbor host of this process. */
|
|
478
|
+
get hostService() {
|
|
479
|
+
const host = this.runtime.ctx.get('arborHost');
|
|
480
|
+
if (host === undefined) {
|
|
481
|
+
throw new Error('arbor driver: no arborHost service (load @4fu/dsh-arbor-host)');
|
|
482
|
+
}
|
|
483
|
+
return host;
|
|
484
|
+
}
|
|
485
|
+
/** The per-session arbor world, for tests and diagnostics. */
|
|
486
|
+
sessionArbor(sessionId) {
|
|
487
|
+
return this.sessions.get(sessionId);
|
|
488
|
+
}
|
|
489
|
+
/** The agent registered for a session's arbor window, once one exists. */
|
|
490
|
+
windowAgentOf(sessionId) {
|
|
491
|
+
return this.sessions.get(sessionId)?.windowAgentOf;
|
|
492
|
+
}
|
|
493
|
+
/** The system prompt the session's arbor window rendered, once it opened. */
|
|
494
|
+
systemPromptOf(sessionId) {
|
|
495
|
+
const window = this.sessions.get(sessionId)?.windowRef;
|
|
496
|
+
return window === undefined ? undefined : this.hostService.backend.systemPrompt(window);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Whether a persisted session runs under arbor.
|
|
500
|
+
*
|
|
501
|
+
* The creation header names the preset a session STARTED with and is frozen
|
|
502
|
+
* because it is a creation fact; a session may still select another while it
|
|
503
|
+
* is blank, and DSH's own reconstruction rule for exactly this reason reads
|
|
504
|
+
* the `agentPreset` projection, "never the header alone"
|
|
505
|
+
* (`@deepseek-ai/dsh-agent-preset-registry`, `lib/types/session.js`). So the
|
|
506
|
+
* log's `agent-preset/selected` events are folded over the header's value:
|
|
507
|
+
* that is what makes a re-routed session come back as arbor after a restart
|
|
508
|
+
* instead of silently resuming on the embedded loop.
|
|
509
|
+
*/
|
|
510
|
+
async ownsSession(sessionId) {
|
|
511
|
+
const persistence = this.runtime.ctx.get('sessionPersistence');
|
|
512
|
+
if (persistence === undefined)
|
|
513
|
+
return false;
|
|
514
|
+
try {
|
|
515
|
+
const handle = await persistence.open(sessionId, 'read');
|
|
516
|
+
try {
|
|
517
|
+
let preset = handle.header.agentPreset;
|
|
518
|
+
const page = await handle.read(0);
|
|
519
|
+
// The event type is declared by the preset registry's module
|
|
520
|
+
// augmentation; read it structurally so building this package alone does
|
|
521
|
+
// not depend on that augmentation being in the program.
|
|
522
|
+
const events = page.events;
|
|
523
|
+
for (const event of events) {
|
|
524
|
+
if (event.type !== 'agent-preset/selected')
|
|
525
|
+
continue;
|
|
526
|
+
const selected = event.data.agentPreset;
|
|
527
|
+
if (typeof selected === 'string')
|
|
528
|
+
preset = selected;
|
|
529
|
+
}
|
|
530
|
+
return preset === ARBOR_PRESET;
|
|
531
|
+
}
|
|
532
|
+
finally {
|
|
533
|
+
await handle.close();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async createAgent(ownerCtx, options) {
|
|
541
|
+
// A session created under the arbor preset runs arbor; every other session
|
|
542
|
+
// runs the embedded AgentLoop. The registry routes both here, because the
|
|
543
|
+
// driver has to hold the embedded handle to be able to re-route a session
|
|
544
|
+
// whose user picks arbor before its first turn.
|
|
545
|
+
if (options.meta?.agentPreset === ARBOR_PRESET)
|
|
546
|
+
return this.createArborAgent(ownerCtx, options);
|
|
547
|
+
const hold = await this.createEmbedded(ownerCtx, options);
|
|
548
|
+
return {
|
|
549
|
+
agent: hold.handle.agent,
|
|
550
|
+
dispose: async () => {
|
|
551
|
+
this.embedded.delete(hold.handle.agent.id);
|
|
552
|
+
await hold.handle.dispose();
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/** Create the agent of a session that runs arbor. */
|
|
557
|
+
async createArborAgent(ownerCtx, options) {
|
|
558
|
+
// An arbor session is not bound to the caller's directory: it works in the
|
|
559
|
+
// arbor shared folder, and a requested directory outside it joins the shared
|
|
560
|
+
// folder only through explicit adoption (see @4fu/dsh-arbor-workspace). The
|
|
561
|
+
// session's cwd is therefore resolved into the shared folder before the
|
|
562
|
+
// session is prepared, so the DSH session, its cwd display and the project
|
|
563
|
+
// picker all name a folder that is really there.
|
|
564
|
+
const requested = options.meta?.cwd;
|
|
565
|
+
const cwd = await this.hostService.resolveArborCwd(requested);
|
|
566
|
+
const meta = options.meta === undefined
|
|
567
|
+
? { cwd }
|
|
568
|
+
: { ...options.meta, cwd };
|
|
569
|
+
const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
|
|
570
|
+
...options.seed === undefined ? {} : { seed: options.seed },
|
|
571
|
+
meta,
|
|
572
|
+
...options.inheritedEventCount === undefined ? {} : { inheritedEventCount: options.inheritedEventCount },
|
|
573
|
+
}));
|
|
574
|
+
const id = preparation.session.id;
|
|
575
|
+
// A directory the user added through DSH's own add-workspace action, named by
|
|
576
|
+
// a session that is starting under arbor, is a pointer for that session: the
|
|
577
|
+
// user chose the directory for this session, and arbor's prompt names it so
|
|
578
|
+
// the model can work in the user's own files. A cwd the session merely
|
|
579
|
+
// inherited is not, which is why the question is asked of the host rather
|
|
580
|
+
// than assumed here.
|
|
581
|
+
this.noteExternalProject(requested, id);
|
|
582
|
+
const agent = this.agentFor(preparation.session, options.agentOptions);
|
|
583
|
+
const arbor = new SessionArbor(this, agent, options.agentOptions, options.setup);
|
|
584
|
+
agent.attachBridge(arbor.bridge());
|
|
585
|
+
this.sessions.set(id, arbor);
|
|
586
|
+
let stored;
|
|
587
|
+
try {
|
|
588
|
+
ownerCtx.fiber.assertActive();
|
|
589
|
+
stored = await this.createStoredSession(preparation.session, options.signal);
|
|
590
|
+
arbor.keepStored(stored);
|
|
591
|
+
await this.publish(ownerCtx, arbor, agent, preparation.session, stored, options.setup, 'startup', options.parentAgent, options.signal);
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
this.sessions.delete(id);
|
|
595
|
+
await arbor.bridge().close();
|
|
596
|
+
await stored?.handle.close().catch(() => { });
|
|
597
|
+
throw error;
|
|
598
|
+
}
|
|
599
|
+
finally {
|
|
600
|
+
preparation[Symbol.dispose]();
|
|
601
|
+
}
|
|
602
|
+
// The session is stored and published, so its header is readable: it now
|
|
603
|
+
// belongs to the workspace that owns the directory it works in, which is
|
|
604
|
+
// what keeps DSH's hero composer live for it.
|
|
605
|
+
await this.hostService.attachArborWorkspace(id, cwd);
|
|
606
|
+
return {
|
|
607
|
+
agent,
|
|
608
|
+
dispose: async () => {
|
|
609
|
+
this.sessions.delete(id);
|
|
610
|
+
await arbor.dispose();
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Create an agent for a session the embedded AgentLoop will run.
|
|
616
|
+
*
|
|
617
|
+
* The embedded factory is delegated to exactly the way the base
|
|
618
|
+
* `AgentRegistry` delegates, so an ordinary session's agent, its ownership and
|
|
619
|
+
* its teardown are the official loop's own. The one difference is that this
|
|
620
|
+
* driver keeps the handle: a session whose user later picks arbor is re-routed
|
|
621
|
+
* by ending the loop's agent and opening the session's arbor window, and
|
|
622
|
+
* ending an agent needs the handle its creation returned.
|
|
623
|
+
*/
|
|
624
|
+
async createEmbedded(ownerCtx, options) {
|
|
625
|
+
const registry = this.runtime.ctx.agents;
|
|
626
|
+
const factory = registry.embedded;
|
|
627
|
+
if (factory === undefined) {
|
|
628
|
+
throw new Error('no agent factory registered (load an agent-loop plugin)');
|
|
629
|
+
}
|
|
630
|
+
const handle = await delegateFactory(ownerCtx, factory, 'createAgent', options);
|
|
631
|
+
const hold = {
|
|
632
|
+
handle,
|
|
633
|
+
agentOptions: options.agentOptions,
|
|
634
|
+
setup: options.setup,
|
|
635
|
+
};
|
|
636
|
+
this.embedded.set(handle.agent.id, hold);
|
|
637
|
+
return hold;
|
|
638
|
+
}
|
|
639
|
+
/** Resume an agent for a session the embedded AgentLoop will run. */
|
|
640
|
+
async resumeEmbedded(ownerCtx, options) {
|
|
641
|
+
const registry = this.runtime.ctx.agents;
|
|
642
|
+
const factory = registry.embedded;
|
|
643
|
+
if (factory === undefined) {
|
|
644
|
+
throw new Error('no agent factory registered (load an agent-loop plugin)');
|
|
645
|
+
}
|
|
646
|
+
const handle = await delegateFactory(ownerCtx, factory, 'resume', options);
|
|
647
|
+
const hold = { handle, agentOptions: options.agentOptions, setup: options.setup };
|
|
648
|
+
this.embedded.set(handle.agent.id, hold);
|
|
649
|
+
return hold;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Resume a persisted arbor session.
|
|
653
|
+
*
|
|
654
|
+
* The session's log is loaded the way the official loop loads it, with two
|
|
655
|
+
* arbor-specific steps around it: the window chain the journal holds is read
|
|
656
|
+
* back and the `tool/call` events the log lost are replayed onto it, so DSH's
|
|
657
|
+
* own `interruptedTurnClosers` closes them as outcome-unknown rather than
|
|
658
|
+
* not-started; and the session is bound to the window the host continued at
|
|
659
|
+
* boot, so its next turn drives that window instead of opening a new chain.
|
|
660
|
+
*/
|
|
661
|
+
async resume(ownerCtx, options) {
|
|
662
|
+
// A persisted session runs arbor when its log says so — the creation header
|
|
663
|
+
// names the preset it started with and a blank session may still have
|
|
664
|
+
// selected another, which is why the log is folded rather than the header
|
|
665
|
+
// read. Everything else runs the embedded AgentLoop, with its handle kept
|
|
666
|
+
// for the same reason `createAgent` keeps one.
|
|
667
|
+
if (await this.ownsSession(options.resumeSessionId)) {
|
|
668
|
+
// A restart resolves the stored cwd the way a creation resolves a requested
|
|
669
|
+
// one, so the session comes back naming the shared folder or the project
|
|
670
|
+
// the user pointed it at — never the directory it happened to be created
|
|
671
|
+
// in before it selected arbor.
|
|
672
|
+
const persistence = this.runtime.ctx.get('sessionPersistence');
|
|
673
|
+
const stored = persistence === undefined ? undefined : await this.storedCwd(persistence, options.resumeSessionId);
|
|
674
|
+
return this.resumeArbor(ownerCtx, {
|
|
675
|
+
...options,
|
|
676
|
+
cwd: await this.hostService.resolveArborCwd(stored),
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
const hold = await this.resumeEmbedded(ownerCtx, options);
|
|
680
|
+
return {
|
|
681
|
+
agent: hold.handle.agent,
|
|
682
|
+
dispose: async () => {
|
|
683
|
+
this.embedded.delete(hold.handle.agent.id);
|
|
684
|
+
await hold.handle.dispose();
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* The cwd a persisted session was created with, read without taking its write
|
|
690
|
+
* ownership: the resume path needs it to resolve the session's cwd before it
|
|
691
|
+
* opens the log for writing.
|
|
692
|
+
*/
|
|
693
|
+
async storedCwd(persistence, sessionId) {
|
|
694
|
+
try {
|
|
695
|
+
const handle = await persistence.open(sessionId, 'read');
|
|
696
|
+
try {
|
|
697
|
+
return handle.header.cwd;
|
|
698
|
+
}
|
|
699
|
+
finally {
|
|
700
|
+
await handle.close();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
return undefined;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/** Resume a persisted session that runs arbor. */
|
|
708
|
+
async resumeArbor(ownerCtx, options) {
|
|
709
|
+
const ctx = this.runtime.ctx;
|
|
710
|
+
const persistence = ctx.get('sessionPersistence');
|
|
711
|
+
if (persistence === undefined) {
|
|
712
|
+
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)');
|
|
713
|
+
}
|
|
714
|
+
const id = options.resumeSessionId;
|
|
715
|
+
// The directory this session works in, read from its stored header before
|
|
716
|
+
// the log is opened for writing: the resume resolves it the same way a
|
|
717
|
+
// creation does, and the workspace attach below names it.
|
|
718
|
+
let open;
|
|
719
|
+
let preparation;
|
|
720
|
+
let stored;
|
|
721
|
+
let agent;
|
|
722
|
+
let arbor;
|
|
723
|
+
let cwd = options.cwd;
|
|
724
|
+
try {
|
|
725
|
+
open = await persistence.open(id, 'write', {
|
|
726
|
+
...options.signal === undefined ? {} : { signal: options.signal },
|
|
727
|
+
});
|
|
728
|
+
cwd ??= open.header.cwd;
|
|
729
|
+
const coldRead = await open.read(0);
|
|
730
|
+
const windows = await this.hostService.windowChainOf(id);
|
|
731
|
+
const recorded = await windowToolCalls(this.hostService.journal, windows);
|
|
732
|
+
const relayed = relayToolCalls(coldRead.events, recorded);
|
|
733
|
+
if (relayed.length > 0)
|
|
734
|
+
await open.append(relayed);
|
|
735
|
+
const closers = interruptedTurnClosers([...coldRead.events, ...relayed]);
|
|
736
|
+
if (closers.length > 0)
|
|
737
|
+
await open.append(closers);
|
|
738
|
+
const seed = [...coldRead.events, ...relayed, ...closers];
|
|
739
|
+
preparation = SessionPreparation.create(ctx.sessions.prepare(id, {
|
|
740
|
+
seed,
|
|
741
|
+
// The cwd a persisted session resumes with is resolved into the shared
|
|
742
|
+
// folder exactly as a creation resolves it, so a session created under
|
|
743
|
+
// arbor, one re-routed onto it and one resumed after a restart all name
|
|
744
|
+
// the same directory: the shared folder, or the project subfolder the
|
|
745
|
+
// user pointed the session at. The stored header keeps what creation
|
|
746
|
+
// wrote; the live session is what the model, the cwd display and the
|
|
747
|
+
// project line all read.
|
|
748
|
+
meta: { ...structuredClone(open.header), cwd: options.cwd ?? open.header.cwd },
|
|
749
|
+
inheritedEventCount: open.inheritedEventCount,
|
|
750
|
+
eventState: coldRead.eventState,
|
|
751
|
+
}));
|
|
752
|
+
stored = { handle: open, storedCount: seed.length };
|
|
753
|
+
open = undefined;
|
|
754
|
+
await this.appendUnstoredSuffix(stored, preparation.session);
|
|
755
|
+
agent = this.agentFor(preparation.session, options.agentOptions);
|
|
756
|
+
arbor = new SessionArbor(this, agent, options.agentOptions, options.setup);
|
|
757
|
+
agent.attachBridge(arbor.bridge());
|
|
758
|
+
this.sessions.set(id, arbor);
|
|
759
|
+
arbor.keepStored(stored);
|
|
760
|
+
await this.publish(ownerCtx, arbor, agent, preparation.session, stored, options.setup, 'resume', options.parentAgent, options.signal);
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
if (agent !== undefined)
|
|
764
|
+
this.sessions.delete(id);
|
|
765
|
+
await arbor?.bridge().close();
|
|
766
|
+
await stored?.handle.close().catch(() => { });
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
preparation?.[Symbol.dispose]();
|
|
771
|
+
await open?.close().catch(() => { });
|
|
772
|
+
}
|
|
773
|
+
// The session is back and its log is open for writing, so its header is
|
|
774
|
+
// readable: it belongs to the workspace that owns the directory it works in,
|
|
775
|
+
// the same one it belonged to before the restart.
|
|
776
|
+
if (cwd !== undefined)
|
|
777
|
+
await this.hostService.attachArborWorkspace(id, cwd);
|
|
778
|
+
const live = { agent, arbor };
|
|
779
|
+
return {
|
|
780
|
+
agent: live.agent,
|
|
781
|
+
dispose: async () => {
|
|
782
|
+
this.sessions.delete(id);
|
|
783
|
+
await live.arbor?.dispose();
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/** Build the session agent for one prepared session, create or resume. */
|
|
788
|
+
agentFor(session, options) {
|
|
789
|
+
return new ArborSessionAgent(this.runtime.ctx, session.id, options ?? {}, session, {
|
|
790
|
+
modelSelection: this.modelProjectionHandle.readerFor(session),
|
|
791
|
+
inbox: this.inboxProjectionHandle.readerFor(session),
|
|
792
|
+
}, this.runtime.ctx.agents.turnBoundaryOf(session).lastTurn);
|
|
793
|
+
}
|
|
794
|
+
/** Take a fresh session's write ownership when persistence is mounted. */
|
|
795
|
+
async createStoredSession(session, signal) {
|
|
796
|
+
const persistence = this.runtime.ctx.get('sessionPersistence');
|
|
797
|
+
if (persistence === undefined)
|
|
798
|
+
return undefined;
|
|
799
|
+
const handle = await persistence.create(session.header, {
|
|
800
|
+
inheritedEventCount: session.inheritedEventCount,
|
|
801
|
+
...signal === undefined ? {} : { signal },
|
|
802
|
+
});
|
|
803
|
+
return { handle, storedCount: 0 };
|
|
804
|
+
}
|
|
805
|
+
/** Durably store the session events appended since the last stored cursor. */
|
|
806
|
+
async appendUnstoredSuffix(stored, session) {
|
|
807
|
+
if (stored === undefined)
|
|
808
|
+
return;
|
|
809
|
+
const suffix = session.snapshotEvents(stored.storedCount);
|
|
810
|
+
if (suffix.length > 0)
|
|
811
|
+
await stored.handle.append(suffix);
|
|
812
|
+
stored.storedCount += suffix.length;
|
|
813
|
+
}
|
|
814
|
+
/** Enter and announce the session and the agent, exactly like AgentLoop does. */
|
|
815
|
+
async publish(ownerCtx, arbor, agent, session, stored, setup, source, parentAgent, signal) {
|
|
816
|
+
const loopCtx = this.runtime.ctx;
|
|
817
|
+
await agent.runMaintenance(async () => {
|
|
818
|
+
if (stored !== undefined) {
|
|
819
|
+
const suffix = session.snapshotEvents(stored.storedCount);
|
|
820
|
+
if (suffix.length > 0)
|
|
821
|
+
await stored.handle.append(suffix);
|
|
822
|
+
stored.storedCount += suffix.length;
|
|
823
|
+
}
|
|
824
|
+
const detachSession = agent.ctx.sessions.enter(session);
|
|
825
|
+
agent.ctx.sessions.announce(session);
|
|
826
|
+
// Every capability appears once: the DSH tools arbor's own replaces are
|
|
827
|
+
// denied in this session's scope, so DSH's own pipeline refuses them and
|
|
828
|
+
// their `tool:*` prompt sections render empty. The same list keeps them
|
|
829
|
+
// out of what the backend hands arbor's catalog
|
|
830
|
+
// (`@4fu/dsh-arbor-tools` DSH_TOOLS_ARBOR_REPLACES). The restriction is
|
|
831
|
+
// scoped to this agent — `tools.restrict` refuses a global one — and is
|
|
832
|
+
// disposed with the agent's own context.
|
|
833
|
+
const liftRestriction = agent.ctx.tools.restrict({
|
|
834
|
+
deny: replacedToolsRegistered(loopCtx.tools),
|
|
835
|
+
});
|
|
836
|
+
const commit = await setup?.(agent.ctx, agent);
|
|
837
|
+
commit?.commit();
|
|
838
|
+
// A `tool:*` section's text reads whether its tool is registered, and the
|
|
839
|
+
// restriction moves that without a registration change, so the prompt
|
|
840
|
+
// bridge is told the prompt moved.
|
|
841
|
+
this.hostService.noteDshPromptChange();
|
|
842
|
+
const detachAgent = loopCtx.agents.enter(agent, parentAgent);
|
|
843
|
+
await loopCtx.agents.announce(agent, source, signal);
|
|
844
|
+
agent.ctx.effect(function* () {
|
|
845
|
+
yield () => {
|
|
846
|
+
detachAgent();
|
|
847
|
+
detachSession();
|
|
848
|
+
};
|
|
849
|
+
yield async () => { await arbor.bridge().close(); };
|
|
850
|
+
}, 'arborDriver.detach()');
|
|
851
|
+
agent.ctx.effect(() => liftRestriction, 'arborDriver.restrictTools()');
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Delegate to one factory exactly the way the base `AgentRegistry` does, so an
|
|
857
|
+
* embedded-loop session's agent, ownership and teardown stay the official loop's
|
|
858
|
+
* own even though this driver is the one calling it.
|
|
859
|
+
*/
|
|
860
|
+
function delegateFactory(ownerCtx, factory, method, options) {
|
|
861
|
+
const target = factory[symbols.original] ?? factory;
|
|
862
|
+
const receiver = getTraceable(ownerCtx, target);
|
|
863
|
+
return Reflect.apply(target[method], receiver, [ownerCtx, options]);
|
|
864
|
+
}
|
|
865
|
+
/** Parse a model-produced arguments string; invalid JSON becomes an empty object. */
|
|
866
|
+
function safeArgs(raw) {
|
|
867
|
+
try {
|
|
868
|
+
const parsed = JSON.parse(raw);
|
|
869
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
870
|
+
? parsed
|
|
871
|
+
: {};
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
return {};
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
/** One refused tool call as the session log's error shape. */
|
|
878
|
+
function toolErrorOf(result) {
|
|
879
|
+
return {
|
|
880
|
+
name: result.error?.rule ?? 'ToolCallFailed',
|
|
881
|
+
code: 'TOOL_CALL_FAILED',
|
|
882
|
+
...result.error === undefined ? {} : { reason: result.error.message },
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
export default ArborDriver;
|