@4fu/dsh-arbor-driver 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/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 +74 -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/session-agent.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arbor session agent: one DSH user session driven through one arbor
|
|
3
|
+
* window.
|
|
4
|
+
*
|
|
5
|
+
* It implements the full `Agent` contract that
|
|
6
|
+
* `packages/core/agent/src/runtime-types.ts` augments onto `Agent` — the same
|
|
7
|
+
* surface `ReactLoopAgent` implements — but the model call and every tool call
|
|
8
|
+
* run through the arbor window instead of the in-process loop:
|
|
9
|
+
*
|
|
10
|
+
* - `followup()`/`steer()`/`inject()` splice the durable inbox and wake the
|
|
11
|
+
* driver, exactly like the official loop (inbox.ts in dsh-agent-loop).
|
|
12
|
+
* - The driver runs turns and steps over the arbor window: `turn/start`,
|
|
13
|
+
* `step/start`, `user/message`, streamed `agent/assistant-stream` frames,
|
|
14
|
+
* `assistant/message`, `ctx.tools.execute` through arbor's tool path,
|
|
15
|
+
* `step/end`, `agent/turn-stopping`, `turn/end`.
|
|
16
|
+
* - Model requests are derived from the session log (`session.deriveMessages()`)
|
|
17
|
+
* exactly like `ReactLoopAgent.buildRequest`.
|
|
18
|
+
*/
|
|
19
|
+
import { AgentModel, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent';
|
|
20
|
+
import { createAssistantMessage, createSystemMessage, errorChain, LlmError } from '@deepseek-ai/dsh-llm';
|
|
21
|
+
import { joinContextSections, renderContextSections } from '@deepseek-ai/dsh-system-prompt';
|
|
22
|
+
import { createScope } from '@deepseek-ai/dsh-scope';
|
|
23
|
+
import { snapshotContextNames } from '@4fu/dsh-arbor-prompt';
|
|
24
|
+
import { ArborInbox } from "./inbox.js";
|
|
25
|
+
import { ArborStreamAttempt } from "./assistant-stream.js";
|
|
26
|
+
import { RuntimeContextProjection } from "./runtime-context.js";
|
|
27
|
+
/** Whether this session's log already carries the system prompt it opened with. */
|
|
28
|
+
function hasSystemMessage(session) {
|
|
29
|
+
for (let seq = 0; seq < session.seq; seq++) {
|
|
30
|
+
if (session.eventAt(seq)?.type === 'system/message')
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
/** Read the cause `cancel()` passed when aborting a loop-owned signal. */
|
|
36
|
+
function abortedCancelCause(signal) {
|
|
37
|
+
if (!signal.aborted)
|
|
38
|
+
return undefined;
|
|
39
|
+
const cause = signal.reason;
|
|
40
|
+
switch (cause.kind) {
|
|
41
|
+
case 'user':
|
|
42
|
+
case 'parent':
|
|
43
|
+
case 'disposed':
|
|
44
|
+
return { kind: cause.kind };
|
|
45
|
+
case 'hook':
|
|
46
|
+
return { kind: 'hook', reason: cause.reason };
|
|
47
|
+
default:
|
|
48
|
+
return { kind: 'user' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** One DSH session driven through one arbor window. */
|
|
52
|
+
export class ArborSessionAgent {
|
|
53
|
+
model;
|
|
54
|
+
inbox;
|
|
55
|
+
scope;
|
|
56
|
+
ctx;
|
|
57
|
+
dispatch;
|
|
58
|
+
bridge;
|
|
59
|
+
phase;
|
|
60
|
+
activityDone = Promise.resolve();
|
|
61
|
+
assistantStreamRevision = 0;
|
|
62
|
+
assistantAttemptCounter = 0;
|
|
63
|
+
systemPromptLogged = false;
|
|
64
|
+
loopCtx;
|
|
65
|
+
id;
|
|
66
|
+
options;
|
|
67
|
+
session;
|
|
68
|
+
/** The window the live step drives, so the turn can end its run. */
|
|
69
|
+
currentWindow;
|
|
70
|
+
/**
|
|
71
|
+
* The session's runtime-context snapshot: the working directory and any
|
|
72
|
+
* third-party runtime context, appended when it changes and never rewritten
|
|
73
|
+
* into the frozen prefix.
|
|
74
|
+
*/
|
|
75
|
+
runtimeContext;
|
|
76
|
+
constructor(loopCtx, id, options, session, projections, lastTurn) {
|
|
77
|
+
this.loopCtx = loopCtx;
|
|
78
|
+
this.id = id;
|
|
79
|
+
this.options = options;
|
|
80
|
+
this.session = session;
|
|
81
|
+
this.dispatch = agentEvents(loopCtx, this);
|
|
82
|
+
this.scope = createScope(loopCtx, this);
|
|
83
|
+
this.ctx = this.scope.ctx;
|
|
84
|
+
this.model = new AgentModel(projections.modelSelection, session, options);
|
|
85
|
+
this.inbox = new ArborInbox(projections.inbox, session, this.dispatch);
|
|
86
|
+
this.phase = { kind: 'idle', lastTurn };
|
|
87
|
+
this.runtimeContext = new RuntimeContextProjection(this.ctx, session);
|
|
88
|
+
// A session resumed from its log already carries the system prompt it
|
|
89
|
+
// opened with, exactly like the official loop's in-history projection.
|
|
90
|
+
this.systemPromptLogged = hasSystemMessage(session);
|
|
91
|
+
}
|
|
92
|
+
/** Attach the arbor window bridge; the driver calls this once the host exists. */
|
|
93
|
+
attachBridge(bridge) {
|
|
94
|
+
this.bridge = bridge;
|
|
95
|
+
}
|
|
96
|
+
get status() {
|
|
97
|
+
return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running';
|
|
98
|
+
}
|
|
99
|
+
/** The turn/step position the live model step logs its tool events at. */
|
|
100
|
+
currentLogPosition() {
|
|
101
|
+
return this.phase.kind === 'running' ? { turn: this.phase.turn, step: this.phase.step } : undefined;
|
|
102
|
+
}
|
|
103
|
+
/** The live turn's abort signal, for tool execution. */
|
|
104
|
+
currentSignal() {
|
|
105
|
+
return this.phase.kind === 'running' ? this.phase.abort.signal : undefined;
|
|
106
|
+
}
|
|
107
|
+
setPhase(next) {
|
|
108
|
+
const previousStatus = this.status;
|
|
109
|
+
this.phase = next;
|
|
110
|
+
const status = this.status;
|
|
111
|
+
if (status !== previousStatus)
|
|
112
|
+
this.dispatch.emit('agent/status', { status });
|
|
113
|
+
}
|
|
114
|
+
send(message, target, wakeup) {
|
|
115
|
+
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted;
|
|
116
|
+
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target;
|
|
117
|
+
this.inbox.splice(resolvedTarget, Infinity, 0, [message]);
|
|
118
|
+
if (wakeup)
|
|
119
|
+
this.wakeDriver(wakingAfterAbort);
|
|
120
|
+
}
|
|
121
|
+
followup(input) {
|
|
122
|
+
this.send(input, 'next-turn', true);
|
|
123
|
+
}
|
|
124
|
+
steer(input) {
|
|
125
|
+
this.send(input, 'next-step', true);
|
|
126
|
+
}
|
|
127
|
+
inject(input) {
|
|
128
|
+
this.send(input, 'next-step', false);
|
|
129
|
+
}
|
|
130
|
+
cancel(cause, options = {}) {
|
|
131
|
+
if (!options.keepInbox) {
|
|
132
|
+
this.inbox.clear();
|
|
133
|
+
if (this.phase.kind !== 'idle')
|
|
134
|
+
this.phase.wakeRequested = false;
|
|
135
|
+
}
|
|
136
|
+
if (this.phase.kind !== 'idle')
|
|
137
|
+
this.phase.abort.abort(cause);
|
|
138
|
+
}
|
|
139
|
+
runMaintenance(job) {
|
|
140
|
+
if (this.phase.kind !== 'idle')
|
|
141
|
+
throw new Error(`agent "${this.id}" already has active work`);
|
|
142
|
+
const done = Promise.withResolvers();
|
|
143
|
+
const maintenance = {
|
|
144
|
+
kind: 'maintenance',
|
|
145
|
+
abort: new AbortController(),
|
|
146
|
+
lastTurn: this.phase.lastTurn,
|
|
147
|
+
wakeRequested: false,
|
|
148
|
+
};
|
|
149
|
+
this.setPhase(maintenance);
|
|
150
|
+
this.activityDone = done.promise;
|
|
151
|
+
return (async () => {
|
|
152
|
+
try {
|
|
153
|
+
return await job(maintenance.abort.signal);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn });
|
|
157
|
+
const cause = abortedCancelCause(maintenance.abort.signal);
|
|
158
|
+
if (cause?.kind !== 'disposed' && maintenance.wakeRequested && this.inbox.hasPending)
|
|
159
|
+
this.wakeDriver();
|
|
160
|
+
done.resolve();
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
}
|
|
164
|
+
wakeDriver(wakeAfterAbort = false) {
|
|
165
|
+
if (this.phase.kind !== 'idle') {
|
|
166
|
+
const reason = abortedCancelCause(this.phase.abort.signal);
|
|
167
|
+
if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) {
|
|
168
|
+
this.phase.wakeRequested = true;
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const driver = Promise.withResolvers();
|
|
173
|
+
this.activityDone = driver.promise;
|
|
174
|
+
this.setPhase({ kind: 'running', abort: new AbortController(), turn: this.phase.lastTurn, step: 0, wakeRequested: false });
|
|
175
|
+
this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject);
|
|
176
|
+
}
|
|
177
|
+
async whenIdle() {
|
|
178
|
+
let activity;
|
|
179
|
+
do {
|
|
180
|
+
await (activity = this.activityDone);
|
|
181
|
+
} while (activity !== this.activityDone);
|
|
182
|
+
}
|
|
183
|
+
throwError(error) {
|
|
184
|
+
const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn;
|
|
185
|
+
const step = this.phase.kind === 'running' ? this.phase.step : 0;
|
|
186
|
+
this.dispatch.emit('agent/error', { turn, step, error });
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
async kick() {
|
|
190
|
+
try {
|
|
191
|
+
while (await this.turn()) { /* the next queued turn runs in the same driver */ }
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Reported failures and cancellation are contained at the driver boundary.
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
if (this.phase.kind === 'running') {
|
|
198
|
+
const { turn, wakeRequested } = this.phase;
|
|
199
|
+
this.setPhase({ kind: 'idle', lastTurn: turn });
|
|
200
|
+
if (wakeRequested && this.inbox.hasPending)
|
|
201
|
+
this.wakeDriver();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async turn() {
|
|
206
|
+
if (this.phase.kind !== 'running') {
|
|
207
|
+
this.throwError(new Error(`agent "${this.id}": turn without driver reservation`));
|
|
208
|
+
}
|
|
209
|
+
const phase = this.phase;
|
|
210
|
+
const { signal } = phase.abort;
|
|
211
|
+
signal.throwIfAborted();
|
|
212
|
+
const turn = phase.turn + 1;
|
|
213
|
+
try {
|
|
214
|
+
this.session.append('turn/start', { turn });
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
this.throwError(error);
|
|
218
|
+
}
|
|
219
|
+
phase.turn = turn;
|
|
220
|
+
let turnEnds = null;
|
|
221
|
+
let target = 'next-turn';
|
|
222
|
+
try {
|
|
223
|
+
while (true) {
|
|
224
|
+
signal.throwIfAborted();
|
|
225
|
+
const step = phase.step + 1;
|
|
226
|
+
const claimed = this.inbox.claim(target, turn);
|
|
227
|
+
const selection = this.model.current();
|
|
228
|
+
if (selection === undefined) {
|
|
229
|
+
throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and model or record a model selection`);
|
|
230
|
+
}
|
|
231
|
+
// `agent/pre-step` is dispatched every step, exactly like the official
|
|
232
|
+
// loop, and a decision's messages are the ones this step admits: a hook
|
|
233
|
+
// that rewrites them is honored. Only `reject` ends the turn early.
|
|
234
|
+
// The fallback is the official loop's own: it appends the runtime
|
|
235
|
+
// context its projection assembled — a third party's per-session
|
|
236
|
+
// context, which a section frozen per epoch cannot state. A hook that
|
|
237
|
+
// rewrites the messages replaces it, exactly like the official loop.
|
|
238
|
+
const decision = await this.dispatch.waterfall('agent/pre-step', { messages: claimed, turn, step, signal, modelSelection: selection }, async () => {
|
|
239
|
+
const snapshot = await this.runtimeContextSnapshot(signal);
|
|
240
|
+
return { kind: 'enter', messages: snapshot === undefined ? claimed : [...claimed, snapshot] };
|
|
241
|
+
});
|
|
242
|
+
signal.throwIfAborted();
|
|
243
|
+
if (decision.kind === 'reject') {
|
|
244
|
+
turnEnds = { kind: 'blocked' };
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
if (turnEnds && decision.messages.length === 0)
|
|
248
|
+
break;
|
|
249
|
+
if (phase.step === 0 && decision.messages.length === 0) {
|
|
250
|
+
turnEnds = { kind: 'completed' };
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
signal.throwIfAborted();
|
|
254
|
+
this.session.append('step/start', { turn, step });
|
|
255
|
+
phase.step = step;
|
|
256
|
+
try {
|
|
257
|
+
const stepEnd = await this.step(decision.messages, turn, step, selection, signal);
|
|
258
|
+
if (turnEnds === null || turnEnds.kind !== 'max-tokens')
|
|
259
|
+
turnEnds = stepEnd;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
this.session.append('step/end', { turn, step });
|
|
263
|
+
}
|
|
264
|
+
signal.throwIfAborted();
|
|
265
|
+
if (turnEnds && this.inbox.nextStep.length === 0) {
|
|
266
|
+
await this.dispatch.serial('agent/turn-stopping', { turn, signal });
|
|
267
|
+
signal.throwIfAborted();
|
|
268
|
+
}
|
|
269
|
+
if (turnEnds && this.inbox.nextStep.length === 0)
|
|
270
|
+
break;
|
|
271
|
+
target = 'next-step';
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
const cause = abortedCancelCause(signal);
|
|
276
|
+
if (cause !== undefined) {
|
|
277
|
+
turnEnds = { kind: 'aborted', reason: cause };
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
turnEnds = {
|
|
281
|
+
kind: 'error',
|
|
282
|
+
error: error instanceof LlmError
|
|
283
|
+
? error.failure
|
|
284
|
+
: { message: errorChain(error), code: 'UNKNOWN' },
|
|
285
|
+
};
|
|
286
|
+
this.throwError(error);
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
try {
|
|
290
|
+
this.session.append('turn/end', { turn, reason: turnEnds });
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
this.throwError(error);
|
|
294
|
+
}
|
|
295
|
+
// The window's run ends with the turn; it stays open for the next one.
|
|
296
|
+
const ended = this.currentWindow;
|
|
297
|
+
this.currentWindow = undefined;
|
|
298
|
+
if (ended !== undefined)
|
|
299
|
+
this.bridge.finishRun(ended);
|
|
300
|
+
}
|
|
301
|
+
if (!this.inbox.hasPending)
|
|
302
|
+
return false;
|
|
303
|
+
phase.abort = new AbortController();
|
|
304
|
+
phase.wakeRequested = false;
|
|
305
|
+
phase.step = 0;
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The session's runtime-context snapshot for this step, or `undefined` when
|
|
310
|
+
* nothing changed since the last one.
|
|
311
|
+
*
|
|
312
|
+
* DSH's own assembly supplies the text, so the wording stays DSH's: the
|
|
313
|
+
* snapshot is the one the official loop would have appended for this session.
|
|
314
|
+
* Only a third party's context is carried — the paths are arbor's own
|
|
315
|
+
* `<workspace>` section's, and the policies are `<harness>`'s.
|
|
316
|
+
*/
|
|
317
|
+
async runtimeContextSnapshot(signal) {
|
|
318
|
+
let assembly;
|
|
319
|
+
try {
|
|
320
|
+
assembly = await this.loopCtx.systemPrompt.assemble({
|
|
321
|
+
...assembleContextFor(this, signal),
|
|
322
|
+
...this.model.current() === undefined ? {} : { modelSelection: this.model.current() },
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
// The assembly runs DSH's own providers, and one of them — the tool
|
|
327
|
+
// presentation row — resolves a tool executor in this agent's scope and
|
|
328
|
+
// throws when none is registered there. The arbor path executes its tools
|
|
329
|
+
// through `ctx.tools.execute` and never mounts DSH's executor, so that
|
|
330
|
+
// provider has nothing to describe. A snapshot is the one thing the
|
|
331
|
+
// pre-step fallback adds, and losing it costs the model a third party's
|
|
332
|
+
// runtime context for one step; losing the turn costs it the step.
|
|
333
|
+
this.ctx.logger.warn(`arbor driver: the runtime-context snapshot was skipped: ${errorChain(error)}`);
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
const carried = new Set(snapshotContextNames(assembly));
|
|
337
|
+
const sections = renderContextSections(assembly).filter(section => carried.has(section.name));
|
|
338
|
+
return this.runtimeContext.project(joinContextSections(sections));
|
|
339
|
+
}
|
|
340
|
+
/** One model call plus the tool executions it requested. */
|
|
341
|
+
async step(messages, turn, step, selection, signal) {
|
|
342
|
+
signal.throwIfAborted();
|
|
343
|
+
const request = messages
|
|
344
|
+
.filter(message => message.source?.kind === 'user')
|
|
345
|
+
.flatMap(message => message.content)
|
|
346
|
+
.filter(block => block.type === 'text')
|
|
347
|
+
.map(block => block.text)
|
|
348
|
+
.join(' ')
|
|
349
|
+
.replace(/\s+/g, ' ')
|
|
350
|
+
.trim();
|
|
351
|
+
const window = await this.bridge.ensureWindow(request === '' ? undefined : request);
|
|
352
|
+
this.currentWindow = window;
|
|
353
|
+
// The system prompt the window's epoch rendered, admitted once per session:
|
|
354
|
+
// a session resumed from its log already carries the one it opened with.
|
|
355
|
+
if (!this.systemPromptLogged) {
|
|
356
|
+
const system = this.bridge.systemPrompt(window);
|
|
357
|
+
if (system.length > 0) {
|
|
358
|
+
this.session.append('system/message', {
|
|
359
|
+
turn,
|
|
360
|
+
step,
|
|
361
|
+
message: createSystemMessage(system),
|
|
362
|
+
}, { surfaceOp: 'append' });
|
|
363
|
+
}
|
|
364
|
+
this.systemPromptLogged = true;
|
|
365
|
+
}
|
|
366
|
+
for (const message of messages) {
|
|
367
|
+
this.session.append('user/message', message, { surfaceOp: 'append' });
|
|
368
|
+
}
|
|
369
|
+
const config = {
|
|
370
|
+
...selection,
|
|
371
|
+
...this.options.maxTokens === undefined ? {} : { maxTokens: this.options.maxTokens },
|
|
372
|
+
};
|
|
373
|
+
const attempt = new ArborStreamAttempt(this.session.id, ++this.assistantAttemptCounter, () => ++this.assistantStreamRevision, turn, step, (frame) => { this.dispatch.emit('agent/assistant-stream', { frame }); });
|
|
374
|
+
attempt.start();
|
|
375
|
+
try {
|
|
376
|
+
await this.bridge.modelCall({
|
|
377
|
+
window,
|
|
378
|
+
agent: this,
|
|
379
|
+
selection,
|
|
380
|
+
...this.options.maxTokens === undefined ? {} : { maxTokens: this.options.maxTokens },
|
|
381
|
+
position: { turn, step },
|
|
382
|
+
signal,
|
|
383
|
+
onChunk: (chunk) => {
|
|
384
|
+
attempt.push(chunk);
|
|
385
|
+
if (chunk.type === 'text-delta') {
|
|
386
|
+
this.bridge.publish(window, { kind: 'text_delta', delta: chunk.text });
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
if (!attempt.ended) {
|
|
393
|
+
if (signal.aborted) {
|
|
394
|
+
const content = attempt.interruptedBlocks();
|
|
395
|
+
if (content.length > 0) {
|
|
396
|
+
attempt.settle('assistant/message', () => this.session.append('assistant/message', {
|
|
397
|
+
turn,
|
|
398
|
+
step,
|
|
399
|
+
message: createAssistantMessage({
|
|
400
|
+
content,
|
|
401
|
+
source: { provider: config.provider, model: config.model },
|
|
402
|
+
}),
|
|
403
|
+
interrupted: true,
|
|
404
|
+
...attempt.usage === undefined ? {} : { usage: attempt.usage },
|
|
405
|
+
stream: attempt.stream,
|
|
406
|
+
}, { surfaceOp: 'append' }).seq);
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
attempt.settle('assistant/attempt', () => this.session.append('assistant/attempt', { turn, step, stream: attempt.stream }).seq);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
attempt.settle('assistant/attempt', () => this.session.append('assistant/attempt', { turn, step, stream: attempt.stream }).seq);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
const message = createAssistantMessage({
|
|
419
|
+
content: attempt.blocks(),
|
|
420
|
+
source: {
|
|
421
|
+
provider: config.provider,
|
|
422
|
+
model: config.model,
|
|
423
|
+
...attempt.replayState === undefined ? {} : { replayState: attempt.replayState },
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
attempt.settle('assistant/message', () => this.session.append('assistant/message', {
|
|
427
|
+
turn,
|
|
428
|
+
step,
|
|
429
|
+
message,
|
|
430
|
+
...attempt.usage === undefined ? {} : { usage: attempt.usage },
|
|
431
|
+
stream: attempt.stream,
|
|
432
|
+
}, { surfaceOp: 'append' }).seq);
|
|
433
|
+
if (attempt.finish.kind === 'max-tokens')
|
|
434
|
+
return { kind: 'max-tokens' };
|
|
435
|
+
const toolCalls = message.content.filter(block => block.type === 'tool-call');
|
|
436
|
+
if (toolCalls.length === 0)
|
|
437
|
+
return { kind: 'completed' };
|
|
438
|
+
try {
|
|
439
|
+
for (const block of toolCalls) {
|
|
440
|
+
if (block.type !== 'tool-call')
|
|
441
|
+
continue;
|
|
442
|
+
const result = await this.bridge.runToolCall(block, { turn, step });
|
|
443
|
+
const call = { name: block.name, args: safeParseArgs(block.arguments), callId: block.id };
|
|
444
|
+
this.bridge.publish(window, { kind: 'tool_call', call });
|
|
445
|
+
this.bridge.publish(window, { kind: 'tool_result', call, result });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
// Every tool result of this step is on the log now; a send's assistant
|
|
450
|
+
// message belongs after them and before the step closes.
|
|
451
|
+
this.bridge.flushSends();
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/** Parse a model-produced arguments string; invalid JSON becomes an empty object. */
|
|
457
|
+
function safeParseArgs(raw) {
|
|
458
|
+
try {
|
|
459
|
+
const parsed = JSON.parse(raw);
|
|
460
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
461
|
+
? parsed
|
|
462
|
+
: {};
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return {};
|
|
466
|
+
}
|
|
467
|
+
}
|