@chatpanel/events 0.92.1 → 0.95.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/turn-loop.js ADDED
@@ -0,0 +1,441 @@
1
+ // The turn loop — a model that asks for tools gets them run, and is asked again.
2
+ //
3
+ // One request either ends with an answer or with tool calls. On the second, the calls are
4
+ // run here and their results go back as the next request, until the model answers in words,
5
+ // the round cap is reached, or the guard decides the model is going in circles. That loop
6
+ // was written three times — once per provider in the extension, once in the desktop — and
7
+ // each copy knew something the others did not: the extension withheld tools on the last
8
+ // round and noticed a round repeating itself; the desktop kept a transcript, survived an
9
+ // abort with the words so far, and nudged a relayed CLI agent that ignores "no tools" until
10
+ // it answered. A fix to one never reached the other two. Now there is one loop and three
11
+ // bindings, each about thirty lines.
12
+ //
13
+ // What is injected, because it is the host's:
14
+ // • `stream(req)` — ONE model request: the provider call, its SSE decoding, its auth.
15
+ // Returns `{ ok, text, toolCalls, usage, aborted, error, finish,
16
+ // blocks?, noVision? }`. A thrown error propagates untouched — the
17
+ // extension's failover reads it.
18
+ // • `tools.execute` — what a call does. The toolset also carries `specs`, `traits`,
19
+ // `remoteTools`, `serialTools`.
20
+ // • `transcript` — how the asked/answered pair is written in this provider's wire
21
+ // shape. OpenAI (also what the gateway relays) and Anthropic ship
22
+ // here; a host with a third shape brings its own.
23
+ // • the callbacks — deltas, activity, steps, wire messages.
24
+ //
25
+ // What is NOT injected, because it is the point: the guard, the round, the cap, the
26
+ // exhaustion, the accounting. Class R with an async seam: no I/O of its own, no clock.
27
+
28
+ import { runToolRound } from './tool-round.js';
29
+ import { toolTraits, effectiveToolName, parallelEligible } from './tool-traits.js';
30
+ import { createToolLoopGuard, roundSignature, toolMadeProgress, blockedToolResult } from './tool-loop-guard.js';
31
+ import { createAdaptiveToolPolicy, resultText } from './adaptive-tool-policy.js';
32
+ import { toolStatus } from './tool-hints.js';
33
+
34
+ /** Model requests one turn may make when tools are armed. Configurable, 60 is the ceiling either client ran with. */
35
+ export const DEFAULT_MAX_ROUNDS = 60;
36
+ /** How many stray tool calls a closing request (no tools offered) is answered before the turn ends anyway. */
37
+ export const DEFAULT_MAX_FINISH_TRIES = 6;
38
+ /** What a step shows of a result — the model receives the whole thing. */
39
+ export const STEP_RESULT_MAX_CHARS = 4000;
40
+ /** Rounds of one turn are separated in the text the user reads. */
41
+ export const ROUND_SEPARATOR = '\n\n';
42
+
43
+ /**
44
+ * A relayed CLI agent keeps its OWN session and its own tools, so "no tools offered" does
45
+ * not stop it asking — it answered the closing request with another call and an empty text,
46
+ * and a member's whole answer was its opening sentence. Each such call is answered with the
47
+ * next of these until words come back.
48
+ */
49
+ export const FINISH_NUDGES = Object.freeze([
50
+ 'The tool budget for this turn is spent. Do not call tools again; write your answer now with what you have, including your findings.',
51
+ 'No more tool calls will be answered. Reply with your answer as plain text, now — a partial answer beats none.',
52
+ 'FINAL: any further tool call ends this turn with no answer. Write what you have found, as text, in this message.',
53
+ ]);
54
+ export const LOOPING_NUDGE = 'You have repeated the same tool call several times. Do not call tools again; answer now with what you have.';
55
+ /** Appended by a client that renders the exhausted flag as words — kept here so both say the same thing. */
56
+ export const EXHAUSTED_NOTE = '_(Reached the action limit for one turn — say "continue" to keep going.)_';
57
+
58
+ /**
59
+ * How many rounds this turn may take. The agent's own setting wins, then the user's
60
+ * preference, then the default; a turn without tools is one request.
61
+ */
62
+ export function roundCap({ tools, agent, settings, fallback = DEFAULT_MAX_ROUNDS } = {}) {
63
+ if (!tools) return 1;
64
+ const ceiling = Math.max(1, Number(fallback) || DEFAULT_MAX_ROUNDS);
65
+ const own = Number(agent?.maxRequestsPerTurn) || 0;
66
+ if (own > 0) return Math.min(ceiling, own);
67
+ const pref = Number(settings?.ui?.maxToolRoundsPerTurn ?? settings?.maxToolRoundsPerTurn) || 0;
68
+ if (pref > 0) return Math.min(ceiling, pref);
69
+ return ceiling;
70
+ }
71
+
72
+ function safeJson(s) {
73
+ if (!s) return {};
74
+ if (typeof s === 'object') return s;
75
+ try { return JSON.parse(s); } catch { return {}; }
76
+ }
77
+
78
+ const argString = (c) => (typeof c.arguments === 'string' ? c.arguments : JSON.stringify(c.arguments ?? c.input ?? {}));
79
+
80
+ /** What the model reads back from a tool: the `string | { text }` executor contract. */
81
+ export { resultText };
82
+
83
+ /** A short, display-safe slice of a result for a step — the model still gets the full result. */
84
+ export function stepResultText(result) {
85
+ const s = String(resultText(result) || '');
86
+ return s.length > STEP_RESULT_MAX_CHARS ? `${s.slice(0, STEP_RESULT_MAX_CHARS)}…` : s;
87
+ }
88
+
89
+ /** One line for the activity trail — the real action and the argument that identifies it. */
90
+ export function describeCall(name, input) {
91
+ const eff = effectiveToolName(name, input);
92
+ const args = input && typeof input === 'object' && input.args && typeof input.args === 'object' ? input.args : (input || {});
93
+ const key = ['query', 'id', 'tool', 'location', 'ref'].find((k) => typeof args[k] === 'string' && args[k]);
94
+ return key ? `${eff} "${String(args[key]).slice(0, 80)}"` : eff;
95
+ }
96
+
97
+ /** Put the toolset's guidance in front of the model, merged into an existing system turn. */
98
+ export function withToolSystem(messages, system) {
99
+ const list = Array.isArray(messages) ? [...messages] : [];
100
+ const text = String(system || '').trim();
101
+ if (!text) return list;
102
+ const i = list.findIndex((m) => m?.role === 'system' && typeof m.content === 'string');
103
+ if (i >= 0) { list[i] = { ...list[i], content: `${list[i].content}\n\n${text}` }; return list; }
104
+ return [{ role: 'system', content: text }, ...list];
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------------------
108
+ // Usage — adds up across rounds, in both key styles, so a team budget reading
109
+ // `prompt_tokens` and a ledger reading `inputTokens` see the same turn.
110
+ // ---------------------------------------------------------------------------------------
111
+
112
+ const n = (v) => Number(v) || 0;
113
+
114
+ export function normalizeUsage(u) {
115
+ if (!u || typeof u !== 'object') return null;
116
+ const inputTokens = n(u.inputTokens ?? u.input_tokens ?? u.prompt_tokens);
117
+ const outputTokens = n(u.outputTokens ?? u.output_tokens ?? u.completion_tokens);
118
+ const cacheReadTokens = n(u.cacheReadTokens ?? u.cache_read_input_tokens ?? u.prompt_tokens_details?.cached_tokens);
119
+ const cacheWriteTokens = n(u.cacheWriteTokens ?? u.cache_creation_input_tokens);
120
+ const out = {
121
+ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
122
+ prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: n(u.total_tokens) || inputTokens + outputTokens,
123
+ calls: n(u.calls) || 1,
124
+ reported: u.reported !== false && (inputTokens > 0 || outputTokens > 0),
125
+ };
126
+ const usd = n(u.usd ?? u.cost);
127
+ if (usd) out.usd = usd;
128
+ return out;
129
+ }
130
+
131
+ /** Two usage records as one. Either may be in a provider's raw shape. */
132
+ export function addUsage(a, b) {
133
+ const A = normalizeUsage(a); const B = normalizeUsage(b);
134
+ if (!B) return A;
135
+ if (!A) return B;
136
+ const out = {
137
+ inputTokens: A.inputTokens + B.inputTokens, outputTokens: A.outputTokens + B.outputTokens,
138
+ cacheReadTokens: A.cacheReadTokens + B.cacheReadTokens, cacheWriteTokens: A.cacheWriteTokens + B.cacheWriteTokens,
139
+ calls: A.calls + B.calls, reported: A.reported || B.reported,
140
+ };
141
+ out.prompt_tokens = out.inputTokens; out.completion_tokens = out.outputTokens; out.total_tokens = A.total_tokens + B.total_tokens;
142
+ const usd = n(A.usd) + n(B.usd);
143
+ if (usd) out.usd = usd;
144
+ return out;
145
+ }
146
+
147
+ /** ~4 chars/token — ONLY when a provider reported nothing. No tokenizer: real usage is accurate and free. */
148
+ export function estimateTokens(text) {
149
+ return Math.max(0, Math.round(String(text || '').length / 4));
150
+ }
151
+
152
+ function estimatedUsage(messages, text) {
153
+ const inText = (messages || []).map((m) => (typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || ''))).join('\n');
154
+ return { inputTokens: estimateTokens(inText), outputTokens: estimateTokens(text), cacheReadTokens: 0, cacheWriteTokens: 0, estimated: true };
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------------------
158
+ // Transcripts — the asked/answered pair in a provider's wire shape.
159
+ // ---------------------------------------------------------------------------------------
160
+
161
+ /** The OpenAI chat shape: what the gateway relays and every provider behind it understands. */
162
+ export const openAiTranscript = Object.freeze({
163
+ asked(res, calls) {
164
+ return {
165
+ role: 'assistant',
166
+ content: String(res?.text || '') || null,
167
+ tool_calls: calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: argString(c) } })),
168
+ };
169
+ },
170
+ answered(pairs, { noVision = false } = {}) {
171
+ const out = pairs.map(({ call, result }) => ({ role: 'tool', tool_call_id: call.id, content: resultText(result) }));
172
+ // A tool message cannot carry an image. A screenshot goes back as a user message AFTER
173
+ // the round's tool messages (a user turn between two tool turns is rejected by strict
174
+ // providers), or as a note once the model has said it has no vision.
175
+ for (const { call, result } of pairs) {
176
+ const image = result && typeof result === 'object' ? result.image : null;
177
+ if (!image) continue;
178
+ out.push(noVision
179
+ ? { role: 'user', content: `(Screenshot from ${call.name} omitted — this model has no vision. Rely on read_canvas / inspect_page / tool results.)` }
180
+ : { role: 'user', content: [{ type: 'text', text: `(Screenshot from ${call.name})` }, { type: 'image_url', image_url: { url: image } }] });
181
+ }
182
+ return out;
183
+ },
184
+ nudge(calls, text) {
185
+ return [
186
+ { role: 'assistant', content: null, tool_calls: calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: argString(c) } })) },
187
+ ...calls.map((c) => ({ role: 'tool', tool_call_id: c.id, content: text })),
188
+ ];
189
+ },
190
+ system: (text) => ({ role: 'system', content: text }),
191
+ said: (text) => ({ role: 'assistant', content: text }),
192
+ });
193
+
194
+ /** The Anthropic Messages shape: content blocks, every result of a round in ONE user turn. */
195
+ export const anthropicTranscript = Object.freeze({
196
+ asked(res, calls) {
197
+ // Echo the assistant's own blocks when the adapter kept them (text and tool_use in the
198
+ // order they came), dropping empty text — the API rejects zero-length text content.
199
+ const blocks = Array.isArray(res?.blocks) && res.blocks.length
200
+ ? res.blocks.filter((b) => b && (b.type === 'tool_use' || (b.type === 'text' && b.text))).map((b) => (b.type === 'tool_use' ? { type: 'tool_use', id: b.id, name: b.name, input: b.input ?? safeJson(b.json) } : { type: 'text', text: b.text }))
201
+ : [...(res?.text ? [{ type: 'text', text: String(res.text) }] : []), ...calls.map((c) => ({ type: 'tool_use', id: c.id, name: c.name, input: c.input }))];
202
+ return { role: 'assistant', content: blocks };
203
+ },
204
+ answered(pairs) {
205
+ return [{
206
+ role: 'user',
207
+ content: pairs.map(({ call, result }) => {
208
+ const text = resultText(result);
209
+ const image = result && typeof result === 'object' ? result.image : null;
210
+ if (!image) return { type: 'tool_result', tool_use_id: call.id, content: text };
211
+ const im = /^data:([^;]+);base64,(.+)$/s.exec(image);
212
+ const content = [];
213
+ if (im) content.push({ type: 'image', source: { type: 'base64', media_type: im[1], data: im[2] } });
214
+ content.push({ type: 'text', text });
215
+ return { type: 'tool_result', tool_use_id: call.id, content };
216
+ }),
217
+ }];
218
+ },
219
+ nudge(calls, text) {
220
+ return [
221
+ { role: 'assistant', content: calls.map((c) => ({ type: 'tool_use', id: c.id, name: c.name, input: c.input })) },
222
+ { role: 'user', content: calls.map((c) => ({ type: 'tool_result', tool_use_id: c.id, content: text })) },
223
+ ];
224
+ },
225
+ // No system role in the message list — the instruction rides as the user's words.
226
+ system: (text) => ({ role: 'user', content: text }),
227
+ said: (text) => ({ role: 'assistant', content: text }),
228
+ });
229
+
230
+ // ---------------------------------------------------------------------------------------
231
+ // Calls — one guarded call, one guarded round. The same code answers a call that arrives
232
+ // mid-stream from a CLI agent (the bridge relays one at a time) and a round of calls from
233
+ // an API model.
234
+ // ---------------------------------------------------------------------------------------
235
+
236
+ /**
237
+ * @param tools the toolset
238
+ * @param guard a tool-loop guard (one per turn)
239
+ * @param policy an adaptive tool policy (one per turn)
240
+ * @param modelLabel `() => string` — WHICH model made this call, read per call: a turn can
241
+ * change model mid-flight (failover), and attributing every action to
242
+ * whichever model finished misreports the work
243
+ * @param maxCalls after this many calls in the turn, each further one is answered with a
244
+ * "budget spent" nudge instead of running — the cap for a host that has
245
+ * no rounds to count (a relayed CLI agent). 0 = no cap.
246
+ * @param onStep `(step)` — `{ phase, callId, name, action, input, text, status, result, image, model }`
247
+ */
248
+ export function createCallRunner({ tools, guard = createToolLoopGuard(), policy = createAdaptiveToolPolicy(), modelLabel = () => null, maxCalls = 0, onStep = null, steps = [] } = {}) {
249
+ let made = 0;
250
+ const traitsOf = (c) => {
251
+ const eff = effectiveToolName(c.name, c.input);
252
+ return tools?.traits?.get(eff) || tools?.traits?.get(c.name) || toolTraits({ name: eff });
253
+ };
254
+ const stepOf = (c, phase, result) => {
255
+ const step = { phase, callId: c.id, name: c.name, action: effectiveToolName(c.name, c.input), input: c.input, text: describeCall(c.name, c.input), model: modelLabel() };
256
+ if (phase === 'done') {
257
+ const image = result && typeof result === 'object' ? result.image : undefined;
258
+ Object.assign(step, { status: toolStatus(result), result: stepResultText(result), ...(image ? { image } : {}) });
259
+ }
260
+ return step;
261
+ };
262
+ const start = (c) => { try { onStep?.(stepOf(c, 'start')); } catch { /* reporting never breaks a turn */ } };
263
+ const done = (c, result) => {
264
+ const step = stepOf(c, 'done', result);
265
+ steps.push(step);
266
+ try { onStep?.(step); } catch { /* reporting never breaks a turn */ }
267
+ };
268
+ const settle = (c, g, result) => {
269
+ policy.recordResult(c.name, result);
270
+ if (!g.blocked && !g.replayed && toolMadeProgress(c.name, result, c.input)) guard.reset(g.key);
271
+ // Only a read is remembered for replay — the traits decide, not a list of names.
272
+ if (!g.replayed) guard.remember(g.key, c.name, c.input, result, { readOnly: !!traitsOf(c)?.readOnly });
273
+ };
274
+ const spent = (c) => blockedToolResult(c.name, FINISH_NUDGES[Math.min(Math.max(0, made - maxCalls - 1), FINISH_NUDGES.length - 1)], { budget: 'spent', calls: made, maxCalls });
275
+ const execute = async (c, g, meta) => {
276
+ made += 1;
277
+ if (maxCalls > 0 && made > maxCalls) return spent(c);
278
+ if (g.blocked || g.replayed) return g.result;
279
+ if (typeof tools?.execute !== 'function') return JSON.stringify({ error: 'no tools armed' });
280
+ return tools.execute(c.name, c.input, { callId: c.id, ...(meta || {}) });
281
+ };
282
+
283
+ return {
284
+ guard, policy, steps, traitsOf,
285
+ get calls() { return made; },
286
+ get exhausted() { return maxCalls > 0 && made >= maxCalls; },
287
+
288
+ /** One call, arriving on its own (a relayed agent). Every exit produces a result. */
289
+ async one(call, meta = null) {
290
+ const c = { id: call.id, name: call.name, input: call.input ?? safeJson(call.arguments) };
291
+ start(c);
292
+ const g = guard.check(c.name, c.input);
293
+ let result;
294
+ try { result = await execute(c, g, meta); } catch (e) { result = JSON.stringify({ error: String(e?.message || e) }); }
295
+ settle(c, g, result);
296
+ done(c, result);
297
+ return result;
298
+ },
299
+
300
+ /**
301
+ * One ROUND — reads overlapped, writes in the model's order, identical calls coalesced
302
+ * (tool-round.js). The guard is consulted up front in the model's order (its counts are
303
+ * order-dependent); the policy and the guard's memory are updated from each result.
304
+ */
305
+ async round(wanted) {
306
+ const calls = wanted.map((c) => ({ id: c.id, name: c.name, input: c.input ?? safeJson(c.arguments) }));
307
+ const guards = calls.map((c) => guard.check(c.name, c.input));
308
+ const { results } = await runToolRound(calls, {
309
+ execute: (c, i) => execute(c, guards[i]),
310
+ traitsOf,
311
+ concurrent: (c, t) => parallelEligible(tools, c, t),
312
+ onStart: (c) => start(c),
313
+ onDone: (c, i, result) => done(c, result),
314
+ });
315
+ results.forEach((result, i) => settle(calls[i], guards[i], result));
316
+ const blocked = guards.filter((g) => g.blocked).length;
317
+ guard.noteRound(blocked, calls.length, roundSignature(calls));
318
+ return { calls, results, blocked };
319
+ },
320
+ };
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------------------
324
+ // The loop.
325
+ // ---------------------------------------------------------------------------------------
326
+
327
+ /**
328
+ * Run a turn to completion.
329
+ *
330
+ * @param stream `(req) => { ok, text, toolCalls?, usage?, error?, aborted?, finish?, blocks?, noVision? }`
331
+ * — ONE request. `req` is `{ model, messages, tools, signal, redaction, run,
332
+ * onDelta(delta), onActivity }`; `req.tools` is the CANONICAL spec list
333
+ * (`{ name, description, parameters }`) or null when none are offered — the
334
+ * adapter shapes it for its provider.
335
+ * @param tools `{ specs, execute, traits?, remoteTools?, serialTools?, system? }`; absent = one plain request
336
+ * @param messages the wire messages as the host assembled them (system turns included)
337
+ * @param transcript how asked/answered are written — `openAiTranscript` (default) or `anthropicTranscript`
338
+ * @param maxRounds model requests with tools; see `roundCap`
339
+ * @param maxFinishTries stray calls answered on the closing request before giving up
340
+ * @param onDelta `(delta, text)` — `text` is everything said so far ACROSS rounds
341
+ * @param onEvent the extension's activity stream: `{type:'tool'|'finish'|'usage', …}`
342
+ * @param onStep the desktop's activity trail: one step per call, start and done
343
+ * @param onMessage `(msg)` each wire message the moment it exists — a record that grows as
344
+ * the turn goes, so a process that dies mid-turn leaves the work so far
345
+ * @param usageLabel `{ provider, model }` stamped on the usage event
346
+ * @returns `{ ok, text, usage, rounds, steps, transcript, exhausted, aborted, error, finish }`
347
+ */
348
+ export async function runTurnLoop({
349
+ model, messages, tools, signal, redaction, run, stream,
350
+ transcript = openAiTranscript,
351
+ maxRounds = DEFAULT_MAX_ROUNDS, maxFinishTries = DEFAULT_MAX_FINISH_TRIES,
352
+ guard = createToolLoopGuard(), policy = createAdaptiveToolPolicy(),
353
+ modelLabel = () => model || null, usageLabel = null,
354
+ onDelta = null, onEvent = null, onStep = null, onMessage = null, onActivity = null,
355
+ } = {}) {
356
+ if (typeof stream !== 'function') throw new Error('runTurnLoop: stream required');
357
+ const armed = !!(tools && Array.isArray(tools.specs) && tools.specs.length);
358
+ const specs = armed ? tools.specs : null;
359
+ const cap = armed ? Math.max(1, Number(maxRounds) || DEFAULT_MAX_ROUNDS) : 1;
360
+ const steps = [];
361
+ const runner = createCallRunner({
362
+ tools, guard, policy, modelLabel, steps,
363
+ onStep: (step) => {
364
+ try { onStep?.(step); } catch { /* never break a turn */ }
365
+ try { onEvent?.({ type: 'tool', ...step }); } catch { /* never break a turn */ }
366
+ },
367
+ });
368
+
369
+ let convo = [...(messages || [])];
370
+ let said = ''; // everything the model has said so far, across rounds
371
+ let usage = null;
372
+ let rounds = 0;
373
+ let noVision = false;
374
+ let finishTries = 0;
375
+ let exhausted = false;
376
+ const push = (msgs) => { for (const m of msgs) { convo = [...convo, m]; try { onMessage?.(m); } catch { /* never break a turn */ } } };
377
+ const finish = (reason) => { try { onEvent?.({ type: 'finish', reason }); } catch { /* ignore */ } };
378
+ const usageEvent = (text) => {
379
+ if (!onEvent) return;
380
+ const u = usage && usage.reported ? { ...usage, estimated: false } : estimatedUsage(convo, text);
381
+ try { onEvent({ type: 'usage', provider: usageLabel?.provider || 'unknown', model: usageLabel?.model || model || null, inputTokens: u.inputTokens, outputTokens: u.outputTokens, cacheReadTokens: u.cacheReadTokens, cacheWriteTokens: u.cacheWriteTokens, estimated: !!u.estimated }); } catch { /* ignore */ }
382
+ };
383
+ const result = (over) => ({ ok: true, text: said, usage, rounds, steps, transcript: convo, exhausted, aborted: false, ...over });
384
+ const closeWith = (text, over = {}) => {
385
+ const t = String(text || '');
386
+ if (t.trim()) push([transcript.said(t)]);
387
+ return result(over);
388
+ };
389
+
390
+ // eslint-disable-next-line no-constant-condition
391
+ while (true) {
392
+ rounds += 1;
393
+ // The closing request: the cap is reached, the guard says the model is circling, or a
394
+ // stray call already came back to a request that offered nothing. No tools, so the turn
395
+ // ends with words — and if the agent asks anyway, it is answered until it stops.
396
+ const closing = !armed || rounds >= cap || guard.stalled || guard.looping || finishTries > 0;
397
+ const offered = closing ? null : specs.filter((s) => !policy.isSuppressed(s?.name));
398
+ let roundText = '';
399
+ const res = await stream({
400
+ model, messages: convo, tools: offered && offered.length ? offered : null, signal, onActivity,
401
+ // Only when set: a host reads these as "present", not as a value.
402
+ ...(redaction !== undefined ? { redaction } : {}), ...(run ? { run } : {}),
403
+ onDelta: (delta) => {
404
+ if (!delta) return;
405
+ if (!roundText && said) { said += ROUND_SEPARATOR; try { onDelta?.(ROUND_SEPARATOR, said); } catch { /* ignore */ } }
406
+ roundText += delta; said += delta;
407
+ try { onDelta?.(delta, said); } catch { /* ignore */ }
408
+ },
409
+ });
410
+ if (res?.usage) usage = addUsage(usage, res.usage);
411
+ if (res?.noVision) noVision = true;
412
+ // Reconcile: an adapter that returned text without streaming it still gets it into `said`.
413
+ const text = String(res?.text || '');
414
+ if (text && !roundText) { if (said) said += ROUND_SEPARATOR; said += text; roundText = text; }
415
+ if (!res?.ok) { finish('error'); return result({ ok: false, error: res?.error || 'the model did not answer', aborted: !!res?.aborted, transcript: text.trim() ? [...convo, transcript.said(said)] : convo }); }
416
+ if (res.aborted || signal?.aborted) { finish('aborted'); return closeWith(said, { aborted: true }); }
417
+
418
+ const wanted = (Array.isArray(res.toolCalls) ? res.toolCalls : []).filter((c) => c && c.name).map((c) => ({ id: c.id, name: c.name, input: c.input ?? safeJson(c.arguments), arguments: c.arguments }));
419
+ if (!wanted.length) {
420
+ finish(res.finish || 'stop');
421
+ usageEvent(said);
422
+ return closeWith(said);
423
+ }
424
+
425
+ if (!offered || !offered.length) {
426
+ // Asked with nothing offered. A relayed agent does this; answer, don't drop.
427
+ finishTries += 1;
428
+ if (finishTries > maxFinishTries) { finish('tool-step-limit'); usageEvent(said); return closeWith(said, { exhausted: true }); }
429
+ exhausted = exhausted || rounds >= cap;
430
+ push(transcript.nudge(wanted, FINISH_NUDGES[Math.min(finishTries - 1, FINISH_NUDGES.length - 1)]));
431
+ continue;
432
+ }
433
+
434
+ push([transcript.asked(res, wanted)]);
435
+ const { calls, results } = await runner.round(wanted);
436
+ push(transcript.answered(calls.map((call, i) => ({ call, result: results[i] })), { noVision }));
437
+ if (signal?.aborted) { finish('aborted'); return closeWith(said, { aborted: true }); }
438
+ if (guard.looping) push([transcript.system(LOOPING_NUDGE)]);
439
+ if (rounds + 1 >= cap) exhausted = true;
440
+ }
441
+ }