arcane-os 0.13.2 → 0.14.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0
4
+
5
+ - Add opt-in `toolText: {name, field}` and `onToolText(text, call, displayId)`
6
+ to model streaming requests. Decode the selected root string field as tool
7
+ arguments arrive, separately from ordinary text and final tool execution.
8
+ Preserve actual call identity, whitespace, ordered delivery, and cancellation
9
+ across HTTP, native, and browser provider routes.
10
+ - Export `formatConversationClosingReportText(value)` so streamed closing text
11
+ uses the same existing formatting as the complete report.
12
+
13
+ ## 0.13.3
14
+
15
+ - Skip automatic TTS finalization calls when a model stream completes while
16
+ muted. Preserve pending speech cleanup, unmuted flushing, explicit public
17
+ speech methods, and model response callbacks across all streaming routes.
18
+
3
19
  ## 0.13.2
4
20
 
5
21
  - Correct the PWA development guide to describe live descriptor refresh and
@@ -2271,13 +2271,24 @@ function createCompletionAccumulator(modelId, requestId) {
2271
2271
  return completeValue({ push, result, hasToolCalls, correlateToolCalls });
2272
2272
  }
2273
2273
 
2274
- function callbackStreamHandle({ runtime, request, signal, onSettled }) {
2274
+ function callbackStreamHandle({ runtime, request, signal, onSettled, observeToolText = null }) {
2275
2275
  const linked = linkAbortSignal(signal);
2276
2276
  const accumulator = createCompletionAccumulator(request.model ?? null, request.id);
2277
2277
  const chunks = [];
2278
2278
  const waiters = [];
2279
2279
  let ended = false;
2280
2280
  let terminalError = null;
2281
+ let observation = Promise.resolve();
2282
+ let observationError = null;
2283
+
2284
+ function publishChunk(chunk) {
2285
+ if (ended || linked.controller.signal.aborted) return;
2286
+ const publicChunk = projectPublicStreamChunk(chunk);
2287
+ if (publicChunk === OMITTED_PUBLIC_STREAM_DATA) return;
2288
+ const waiter = waiters.shift();
2289
+ if (waiter) waiter.resolve({ value: publicChunk, done: false });
2290
+ else chunks.push(publicChunk);
2291
+ }
2281
2292
 
2282
2293
  function deliver(value) {
2283
2294
  // This gate prevents delivery after public cancellation. It is not proof
@@ -2287,11 +2298,20 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
2287
2298
  ? value
2288
2299
  : { ...value, id: request.id };
2289
2300
  accumulator.push(chunk);
2290
- const publicChunk = projectPublicStreamChunk(chunk);
2291
- if (publicChunk === OMITTED_PUBLIC_STREAM_DATA) return;
2292
- const waiter = waiters.shift();
2293
- if (waiter) waiter.resolve({ value: publicChunk, done: false });
2294
- else chunks.push(publicChunk);
2301
+ if (!observeToolText) {
2302
+ publishChunk(chunk);
2303
+ return;
2304
+ }
2305
+ observation = observation.then(async function observeBrowserWasmChunk() {
2306
+ if (ended) return;
2307
+ throwIfAborted(linked.controller.signal);
2308
+ await observeToolText(chunk);
2309
+ publishChunk(chunk);
2310
+ });
2311
+ observation.catch(function cancelFailedBrowserWasmObservation(error) {
2312
+ observationError ??= error;
2313
+ linked.controller.abort(error);
2314
+ });
2295
2315
  }
2296
2316
 
2297
2317
  function finish(error = null) {
@@ -2313,17 +2333,27 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
2313
2333
  );
2314
2334
  const result = (async () => {
2315
2335
  try {
2316
- await terminal;
2336
+ const terminalValue = await terminal;
2337
+ if (observeToolText) await observation;
2338
+ throwIfAborted(linked.controller.signal);
2339
+ if (observeToolText) await observeToolText(terminalValue);
2317
2340
  throwIfAborted(linked.controller.signal);
2318
2341
  const value = accumulator.result();
2342
+ if (observeToolText) await observeToolText(value);
2343
+ throwIfAborted(linked.controller.signal);
2319
2344
  finish();
2320
2345
  return value;
2321
2346
  } catch (error) {
2322
- const normalized = normalizeArcaneAIError(error, {
2347
+ const normalized = normalizeArcaneAIError(observationError ?? error, {
2323
2348
  kind: "llm",
2324
2349
  operation: "request",
2325
- signal: normalizationSignal(error, linked.controller.signal),
2350
+ signal: observationError ? null : normalizationSignal(error, linked.controller.signal),
2326
2351
  });
2352
+ if (observeToolText) {
2353
+ await observation.catch(function retainFirstBrowserWasmStreamFailure() {
2354
+ // The failure captured above remains the terminal outcome.
2355
+ });
2356
+ }
2327
2357
  finish(normalized);
2328
2358
  throw normalized;
2329
2359
  }
@@ -2380,7 +2410,7 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
2380
2410
  return completeValue(handle);
2381
2411
  }
2382
2412
 
2383
- function validatedV1StreamHandle(opened, request) {
2413
+ function validatedV1StreamHandle(opened, request, observeToolText = null, signal = null) {
2384
2414
  if (
2385
2415
  !opened
2386
2416
  || !is.object(opened)
@@ -2429,6 +2459,16 @@ function validatedV1StreamHandle(opened, request) {
2429
2459
  let publicStreamSettled = false;
2430
2460
  let publicStreamError = null;
2431
2461
 
2462
+ function stopV1ToolTextObservation(reason) {
2463
+ if (!observeToolText) return;
2464
+ publicStreamError ??= reason instanceof Error ? reason : fail(
2465
+ 'ARCANE_AI_REQUEST_ABORTED',
2466
+ 'The browser-WASM request was cancelled.',
2467
+ reason,
2468
+ );
2469
+ settlePublicStream(publicStreamError);
2470
+ }
2471
+
2432
2472
  function publishPublicChunk(value) {
2433
2473
  if (publicStreamSettled) return;
2434
2474
  const waiter = publicChunkWaiters.shift();
@@ -2459,10 +2499,24 @@ function validatedV1StreamHandle(opened, request) {
2459
2499
  return true;
2460
2500
  }
2461
2501
  accumulator.push(next.value);
2502
+ if (observeToolText) {
2503
+ if (publicStreamError !== null) throw publicStreamError;
2504
+ throwIfAborted(signal);
2505
+ await observeToolText(next.value);
2506
+ if (publicStreamError !== null) throw publicStreamError;
2507
+ throwIfAborted(signal);
2508
+ }
2462
2509
  const projected = projectPublicStreamChunk(next.value);
2463
2510
  if (projected !== OMITTED_PUBLIC_STREAM_DATA) publishPublicChunk(projected);
2464
2511
  }
2465
2512
  } catch (error) {
2513
+ if (observeToolText) {
2514
+ Promise.resolve().then(function cancelFailedV1ToolTextStream() {
2515
+ return opened.cancel(error);
2516
+ }).catch(function reportFailedV1ToolTextCancellation(cleanupError) {
2517
+ arcaneLogging.error('Arcane v1 tool-text stream cancellation failed.', cleanupError);
2518
+ });
2519
+ }
2466
2520
  settlePublicStream(error);
2467
2521
  throw error;
2468
2522
  }
@@ -2476,8 +2530,15 @@ function validatedV1StreamHandle(opened, request) {
2476
2530
  );
2477
2531
  terminalResult.catch(function retainV1StreamTerminalRejection() {});
2478
2532
  const result = Promise.all([terminalResult, privateStreamPump]).then(
2479
- function correlateV1StreamTerminal([terminal]) {
2533
+ async function correlateV1StreamTerminal([terminal]) {
2534
+ if (observeToolText && publicStreamError !== null) throw publicStreamError;
2480
2535
  accumulator.correlateToolCalls(terminal);
2536
+ if (observeToolText) {
2537
+ throwIfAborted(signal);
2538
+ await observeToolText(terminal);
2539
+ if (publicStreamError !== null) throw publicStreamError;
2540
+ throwIfAborted(signal);
2541
+ }
2481
2542
  return terminal;
2482
2543
  },
2483
2544
  );
@@ -2485,6 +2546,7 @@ function validatedV1StreamHandle(opened, request) {
2485
2546
  const handle = {
2486
2547
  result,
2487
2548
  cancel: function cancelValidatedV1Stream(reason) {
2549
+ stopV1ToolTextObservation(reason);
2488
2550
  return opened.cancel(reason);
2489
2551
  },
2490
2552
  async next() {
@@ -2496,6 +2558,7 @@ function validatedV1StreamHandle(opened, request) {
2496
2558
  });
2497
2559
  },
2498
2560
  async return(value) {
2561
+ stopV1ToolTextObservation('The stream consumer stopped before completion.');
2499
2562
  if (is.function(iterator.return)) {
2500
2563
  Promise.resolve().then(function returnUnderlyingV1Stream() {
2501
2564
  return iterator.return(value);
@@ -2511,6 +2574,7 @@ function validatedV1StreamHandle(opened, request) {
2511
2574
  return { value, done: true };
2512
2575
  },
2513
2576
  async throw(error) {
2577
+ stopV1ToolTextObservation(error);
2514
2578
  await opened.cancel(error);
2515
2579
  throw error;
2516
2580
  },
@@ -3171,6 +3235,7 @@ export function createBrowserWasmLlmProvider({
3171
3235
  runtime,
3172
3236
  request,
3173
3237
  signal: externalSignal,
3238
+ observeToolText: context.observeToolText,
3174
3239
  onSettled(error) {
3175
3240
  if (settled) return;
3176
3241
  settled = true;
@@ -3452,7 +3517,7 @@ export function adaptV1LlmProvider(provider) {
3452
3517
  });
3453
3518
  return status();
3454
3519
  },
3455
- request({ role = "llm", selection, operation, payload, signal = null } = {}) {
3520
+ request({ role = "llm", selection, operation, payload, signal = null } = {}, controls = {}) {
3456
3521
  assertSelection(selection, role);
3457
3522
  assertActiveSelection(selection);
3458
3523
  throwIfAborted(signal);
@@ -3466,9 +3531,9 @@ export function adaptV1LlmProvider(provider) {
3466
3531
  }
3467
3532
  if (operation === "stream") {
3468
3533
  validateStructuralRequest(payload);
3469
- return Promise.resolve(methods.stream(payload, { signal })).then(
3534
+ return Promise.resolve(methods.stream(payload, { signal, observeToolText: controls.observeToolText })).then(
3470
3535
  function wrapV1StreamResult(opened) {
3471
- return validatedV1StreamHandle(opened, payload);
3536
+ return validatedV1StreamHandle(opened, payload, controls.observeToolText, signal);
3472
3537
  },
3473
3538
  );
3474
3539
  }
@@ -1,6 +1,7 @@
1
1
  import Is from "../dependencies/strong-type/index.js";
2
2
  import { arcaneLogging } from '../logging.mjs';
3
3
  import { createArcaneEventSource } from "arcane-os/event-manager";
4
+ import { createToolTextObserver } from './tool-text-stream.mjs';
4
5
 
5
6
  const is = new Is(false);
6
7
 
@@ -1311,13 +1312,22 @@ export class ModelController {
1311
1312
  }
1312
1313
  }
1313
1314
 
1314
- stream(request = {}) {
1315
+ stream(request = {}, context = {}) {
1315
1316
  this.#assertOperational();
1316
1317
  request=structuralRequest(request);
1317
1318
  localRequirement(request, this.#provider);
1318
1319
  const controller = this;
1319
1320
  const externalSignal = request.signal ?? null;
1320
1321
  const linked = linkedAbortSignal(externalSignal);
1322
+ const observeToolText = context.observeToolText ? async function observeActiveModelToolText(chunk) {
1323
+ if (linked.controller.signal.aborted) {
1324
+ throw normalizeArcaneAIError(null, { operation: 'request', signal: linked.controller.signal });
1325
+ }
1326
+ await context.observeToolText(chunk);
1327
+ if (linked.controller.signal.aborted) {
1328
+ throw normalizeArcaneAIError(null, { operation: 'request', signal: linked.controller.signal });
1329
+ }
1330
+ } : null;
1321
1331
  let opened = null;
1322
1332
  let openedIterator = null;
1323
1333
  let openError = null;
@@ -1363,6 +1373,7 @@ export class ModelController {
1363
1373
  kind: "llm",
1364
1374
  operation: "stream",
1365
1375
  signal: linked.controller.signal,
1376
+ observeToolText,
1366
1377
  },
1367
1378
  );
1368
1379
  if (
@@ -1427,10 +1438,18 @@ export class ModelController {
1427
1438
  return true;
1428
1439
  }
1429
1440
  streamedToolCalls.observe(next.value);
1441
+ if (observeToolText) await observeToolText(next.value);
1430
1442
  const projected=projectPublicStreamChunk(next.value);
1431
1443
  if(projected!==OMITTED_PUBLIC_STREAM_DATA)publishPublicChunk(projected);
1432
1444
  }
1433
1445
  }catch(error){
1446
+ if (observeToolText) {
1447
+ Promise.resolve().then(function cancelFailedModelToolTextStream() {
1448
+ return opened.cancel(error);
1449
+ }).catch(function reportFailedModelToolTextCancellation(cleanupError) {
1450
+ arcaneLogging.error('Arcane model tool-text stream cancellation failed.', cleanupError);
1451
+ });
1452
+ }
1434
1453
  settlePublicStream(error);
1435
1454
  throw error;
1436
1455
  }
@@ -1446,8 +1465,9 @@ export class ModelController {
1446
1465
  });
1447
1466
  terminalResult.catch(()=>undefined);
1448
1467
 
1449
- const result = Promise.all([terminalResult,privateStreamPump]).then(([terminal]) => {
1468
+ const result = Promise.all([terminalResult,privateStreamPump]).then(async function completeModelStream([terminal]) {
1450
1469
  streamedToolCalls.correlate(terminal);
1470
+ if (observeToolText) await observeToolText(terminal);
1451
1471
  settlePublicStream();
1452
1472
  return terminal;
1453
1473
  }).catch((error)=>{
@@ -1533,10 +1553,18 @@ export class ModelController {
1533
1553
  this.#assertOperational();
1534
1554
  localRequirement(options, this.#provider);
1535
1555
  const id = requestIdentity(options.id);
1536
- const request = structuralRequest({ ...options, id });
1556
+ const { toolText, onToolText, ...requestOptions } = options;
1557
+ const request = structuralRequest({ ...requestOptions, id });
1537
1558
  const displayId = displayRequestId(id);
1559
+ const observeToolText = createToolTextObserver(
1560
+ toolText,
1561
+ is.function(onToolText) ? function deliverSelectedToolText(text, call) {
1562
+ return onToolText(text, call, displayId);
1563
+ } : onToolText,
1564
+ { signal: options.signal },
1565
+ );
1538
1566
  fireAndForget(options.onRequest, request, id);
1539
- const handle = this.stream(request);
1567
+ const handle = this.stream(request, { observeToolText });
1540
1568
 
1541
1569
  try {
1542
1570
  for await (const chunk of handle) {
@@ -0,0 +1,364 @@
1
+ import Is from '../dependencies/strong-type/index.js';
2
+
3
+ const is = new Is(false);
4
+ const STRING_ESCAPES = {
5
+ '"': '"',
6
+ '\\': '\\',
7
+ '/': '/',
8
+ b: '\b',
9
+ f: '\f',
10
+ n: '\n',
11
+ r: '\r',
12
+ t: '\t'
13
+ };
14
+
15
+ function projectionError(message, cause) {
16
+ const error = new SyntaxError(
17
+ `Tool text projection ${message}`,
18
+ cause === undefined ? undefined : {cause}
19
+ );
20
+ error.code = 'ARCANE_AI_TOOL_TEXT_INVALID';
21
+ return error;
22
+ }
23
+
24
+ function isWhitespace(character) {
25
+ return character === ' ' || character === '\t'
26
+ || character === '\n' || character === '\r';
27
+ }
28
+
29
+ function createArgumentScanner(field, appendText, beginText, endText) {
30
+ const stack = [];
31
+ let started = false;
32
+ let finished = false;
33
+ let string = null;
34
+ let scalar = null;
35
+
36
+ function finishValue() {
37
+ stack[stack.length - 1].state = 'commaOrEnd';
38
+ }
39
+
40
+ function closeContainer() {
41
+ stack.pop();
42
+ if (stack.length) {
43
+ finishValue();
44
+ } else {
45
+ finished = true;
46
+ }
47
+ }
48
+
49
+ function acceptStringCharacter(character) {
50
+ if (string.key) {
51
+ if (stack.length === 1) string.value += character;
52
+ } else if (string.selected) {
53
+ appendText(character, string.offset);
54
+ string.offset += character.length;
55
+ }
56
+ }
57
+
58
+ function readStringCharacter(character) {
59
+ if (string.unicode !== null) {
60
+ if (!/[0-9a-fA-F]/.test(character)) {
61
+ throw projectionError('encountered an invalid Unicode escape.');
62
+ }
63
+ string.unicode += character;
64
+ if (string.unicode.length === 4) {
65
+ acceptStringCharacter(
66
+ String.fromCharCode(Number.parseInt(string.unicode, 16))
67
+ );
68
+ string.unicode = null;
69
+ }
70
+ return;
71
+ }
72
+ if (string.escaped) {
73
+ string.escaped = false;
74
+ if (character === 'u') {
75
+ string.unicode = '';
76
+ } else if (Object.hasOwn(STRING_ESCAPES, character)) {
77
+ acceptStringCharacter(STRING_ESCAPES[character]);
78
+ } else {
79
+ throw projectionError('encountered an invalid string escape.');
80
+ }
81
+ return;
82
+ }
83
+ if (character === '\\') {
84
+ string.escaped = true;
85
+ } else if (character === '"') {
86
+ const frame = stack[stack.length - 1];
87
+ if (string.key) {
88
+ frame.key = string.value;
89
+ frame.state = 'colon';
90
+ } else {
91
+ if (string.selected) endText(string.offset);
92
+ finishValue();
93
+ }
94
+ string = null;
95
+ } else {
96
+ if (character.charCodeAt(0) < 32) {
97
+ throw projectionError('encountered an unescaped control character.');
98
+ }
99
+ acceptStringCharacter(character);
100
+ }
101
+ }
102
+
103
+ function startString(key, selected = false) {
104
+ string = {key, selected, value: '', offset: 0, escaped: false, unicode: null};
105
+ if (selected) beginText();
106
+ }
107
+
108
+ function startValue(character, frame) {
109
+ const selected = stack.length === 1 && frame.kind === 'object'
110
+ && frame.key === field;
111
+ if (selected && character !== '"') {
112
+ beginText();
113
+ endText(0);
114
+ }
115
+ if (character === '"') {
116
+ startString(false, selected);
117
+ } else if (character === '{') {
118
+ stack.push(
119
+ {kind: 'object', state: 'keyOrEnd', key: ''}
120
+ );
121
+ } else if (character === '[') {
122
+ stack.push(
123
+ {kind: 'array', state: 'valueOrEnd'}
124
+ );
125
+ } else if (character === '-' || character >= '0' && character <= '9'
126
+ || character === 't' || character === 'f' || character === 'n') {
127
+ scalar = character;
128
+ } else {
129
+ throw projectionError('encountered an invalid argument value.');
130
+ }
131
+ }
132
+
133
+ function appendArguments(fragment) {
134
+ for (let position = 0; position < fragment.length; position += 1) {
135
+ const character = fragment[position];
136
+ if (string) {
137
+ readStringCharacter(character);
138
+ continue;
139
+ }
140
+ if (scalar !== null) {
141
+ if (!isWhitespace(character) && character !== ','
142
+ && character !== '}' && character !== ']') {
143
+ scalar += character;
144
+ continue;
145
+ }
146
+ try {
147
+ JSON.parse(scalar);
148
+ } catch (cause) {
149
+ throw projectionError('encountered an invalid argument value.', cause);
150
+ }
151
+ scalar = null;
152
+ finishValue();
153
+ }
154
+ if (isWhitespace(character)) continue;
155
+ if (finished) {
156
+ throw projectionError('encountered content after the argument object.');
157
+ }
158
+ if (!started) {
159
+ if (character !== '{') {
160
+ throw projectionError('requires a root argument object.');
161
+ }
162
+ started = true;
163
+ stack.push(
164
+ {kind: 'object', state: 'keyOrEnd', key: ''}
165
+ );
166
+ continue;
167
+ }
168
+ const frame = stack[stack.length - 1];
169
+ if (frame.state === 'keyOrEnd' || frame.state === 'key') {
170
+ if (character === '"') {
171
+ startString(true);
172
+ } else if (character === '}' && frame.state === 'keyOrEnd') {
173
+ closeContainer();
174
+ } else {
175
+ throw projectionError('encountered an invalid object key.');
176
+ }
177
+ } else if (frame.state === 'colon') {
178
+ if (character !== ':') {
179
+ throw projectionError('expected a colon after an object key.');
180
+ }
181
+ frame.state = 'value';
182
+ } else if (frame.state === 'value' || frame.state === 'valueOrEnd') {
183
+ if (character === ']' && frame.state === 'valueOrEnd') {
184
+ closeContainer();
185
+ } else {
186
+ startValue(character, frame);
187
+ }
188
+ } else if (character === ',') {
189
+ frame.state = frame.kind === 'object' ? 'key' : 'value';
190
+ } else if (character === (frame.kind === 'object' ? '}' : ']')) {
191
+ closeContainer();
192
+ } else {
193
+ throw projectionError('expected a comma or the end of an argument container.');
194
+ }
195
+ }
196
+ }
197
+
198
+ return appendArguments;
199
+ }
200
+
201
+ export function createToolTextObserver(selection, onText, {signal} = {}) {
202
+ if (selection === undefined || selection === null || selection === false) return null;
203
+ if (!is.object(selection) || is.array(selection)
204
+ || !is.string(selection.name) || !selection.name.trim()
205
+ || !is.string(selection.field) || !selection.field.trim()) {
206
+ throw new TypeError('AI toolText must name a tool and a root string field.');
207
+ }
208
+ if (!is.function(onText)) {
209
+ throw new TypeError('AI onToolText must be a function when toolText is selected.');
210
+ }
211
+ const name = selection.name;
212
+ const field = selection.field;
213
+ const choices = new Map();
214
+
215
+ function recordFor(choiceIndex, index) {
216
+ let calls = choices.get(choiceIndex);
217
+ if (!calls) {
218
+ calls = new Map();
219
+ choices.set(choiceIndex, calls);
220
+ }
221
+ let record = calls.get(index);
222
+ if (!record) {
223
+ record = {
224
+ id: '', name: '', index, choiceIndex, pending: [], scanner: null,
225
+ text: '', emitted: 0, textOpen: false
226
+ };
227
+ calls.set(index, record);
228
+ }
229
+ return record;
230
+ }
231
+
232
+ function prepareScanner(record) {
233
+ if (!record.scanner) {
234
+ record.scanner = createArgumentScanner(
235
+ field,
236
+ function appendSelectedToolText(text, offset) {
237
+ for (let index = 0; index < text.length; index += 1) {
238
+ const position = offset + index;
239
+ if (position < record.text.length) {
240
+ if (record.text[position] !== text[index]) {
241
+ throw projectionError('received conflicting selected argument text.');
242
+ }
243
+ } else {
244
+ record.text += text[index];
245
+ }
246
+ }
247
+ },
248
+ function beginSelectedToolText() {
249
+ if (record.emitted === 0) record.text = '';
250
+ record.textOpen = true;
251
+ },
252
+ function finishSelectedToolText(length) {
253
+ if (length < record.text.length) {
254
+ throw projectionError('received a shorter replacement for selected argument text.');
255
+ }
256
+ record.textOpen = false;
257
+ }
258
+ );
259
+ }
260
+ for (const fragment of record.pending) record.scanner(fragment);
261
+ record.pending = [];
262
+ }
263
+
264
+ function observeCompleteArguments(record, argumentsValue) {
265
+ let argumentsObject = argumentsValue;
266
+ if (is.string(argumentsObject)) {
267
+ try {
268
+ argumentsObject = JSON.parse(argumentsObject);
269
+ } catch (cause) {
270
+ throw projectionError('received invalid complete argument JSON.', cause);
271
+ }
272
+ }
273
+ if (!argumentsObject || !is.object(argumentsObject) || is.array(argumentsObject)) {
274
+ throw projectionError('requires a complete argument object.');
275
+ }
276
+ if (!Object.hasOwn(argumentsObject, field)) {
277
+ if (record.emitted > 0) {
278
+ throw projectionError('lost the selected argument field in a complete response.');
279
+ }
280
+ record.text = '';
281
+ record.textOpen = false;
282
+ return;
283
+ }
284
+ const text = argumentsObject[field];
285
+ if (!is.string(text)) {
286
+ throw projectionError('requires the selected argument field to be a string.');
287
+ }
288
+ // A terminal snapshot may repeat a complete value already streamed.
289
+ if (record.emitted > 0 && !text.startsWith(record.text)) {
290
+ throw projectionError('received conflicting complete argument text.');
291
+ }
292
+ record.text = text;
293
+ record.textOpen = false;
294
+ }
295
+
296
+ async function emitSelectedText(record) {
297
+ if (signal?.aborted || record.name !== name || !record.id) return;
298
+ let end = record.text.length;
299
+ if (record.textOpen && end > record.emitted) {
300
+ // Keep a split surrogate pair together without changing its code units.
301
+ const last = record.text.charCodeAt(end - 1);
302
+ if (last >= 0xD800 && last <= 0xDBFF) end -= 1;
303
+ }
304
+ if (end <= record.emitted) return;
305
+ const text = record.text.slice(record.emitted, end);
306
+ record.emitted = end;
307
+ await onText(
308
+ text,
309
+ {id: record.id, name: record.name, field, index: record.index, choiceIndex: record.choiceIndex}
310
+ );
311
+ }
312
+
313
+ async function observeCalls(calls, choiceIndex, complete) {
314
+ if (!is.array(calls)) return;
315
+ for (let position = 0; position < calls.length; position += 1) {
316
+ if (signal?.aborted) return;
317
+ const call = calls[position];
318
+ if (!call || !is.object(call)) continue;
319
+ const record = recordFor(choiceIndex, call.index ?? position);
320
+ if (is.string(call.id) && call.id) {
321
+ if (record.emitted && record.id !== call.id) {
322
+ throw projectionError('changed the identity of a displayed tool call.');
323
+ }
324
+ record.id = call.id;
325
+ }
326
+ const functionValue = call.function && is.object(call.function)
327
+ ? call.function
328
+ : {};
329
+ if (is.string(functionValue.name)) {
330
+ const nextName = complete ? functionValue.name : record.name + functionValue.name;
331
+ if (record.emitted && nextName !== record.name) {
332
+ throw projectionError('changed the name of a displayed tool call.');
333
+ }
334
+ record.name = nextName;
335
+ }
336
+ if (!name.startsWith(record.name)) {
337
+ record.pending = [];
338
+ continue;
339
+ }
340
+ if (!complete && is.string(functionValue.arguments)) {
341
+ record.pending.push(functionValue.arguments);
342
+ }
343
+ if (record.name !== name) continue;
344
+ prepareScanner(record);
345
+ if (complete) observeCompleteArguments(record, functionValue.arguments);
346
+ await emitSelectedText(record);
347
+ }
348
+ }
349
+
350
+ return async function observeToolText(chunk) {
351
+ if (signal?.aborted || !chunk || !is.object(chunk)) return;
352
+ if (is.array(chunk.choices)) {
353
+ for (let position = 0; position < chunk.choices.length; position += 1) {
354
+ if (signal?.aborted) return;
355
+ const choice = chunk.choices[position];
356
+ if (!choice || !is.object(choice)) continue;
357
+ const choiceIndex = choice.index ?? position;
358
+ await observeCalls(choice.delta?.tool_calls, choiceIndex, false);
359
+ await observeCalls(choice.message?.tool_calls, choiceIndex, true);
360
+ }
361
+ }
362
+ if (!signal?.aborted) await observeCalls(chunk.message?.tool_calls, 0, true);
363
+ };
364
+ }
@@ -598,6 +598,12 @@ boundary; `finishTTS()` flushes any remaining text. It is not a playback-ended
598
598
  notification. Do not mute or dispose immediately after it if playback should
599
599
  continue.
600
600
 
601
+ Automatic model-stream completion reads the AI instance's current mute state.
602
+ While muted, it clears pending speech text and formatting state and stops
603
+ prepared playback without invoking `finishTTS()` or `streamTTS()`. Unmuted
604
+ completion keeps its ordinary speech flush. Explicit calls to either public
605
+ speech method retain their existing behavior.
606
+
601
607
  ## Automatic speech-input formatting cleanup
602
608
 
603
609
  Every TTS entrypoint removes repeated same formatting marks from the outbound