arcane-os 0.13.3 → 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 +10 -0
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +79 -14
- package/browser-runtime/ai/model-controller.mjs +32 -4
- package/browser-runtime/ai/tool-text-stream.mjs +364 -0
- package/docs/reference/ai/browser-wasm.md +62 -0
- package/docs/reference/inventory/package-api.json +18 -2
- package/docs/reference/inventory/runtime-modules.json +4 -3
- package/docs/reference/runtime-modules.md +36 -8
- package/docs/reference/sdk-api.md +72 -3
- package/package.json +2 -1
- package/runtime/arcane/modules/AI.js +55 -7
- package/runtime/arcane/modules/AIProviderRuntime.js +24 -7
- package/runtime/arcane/modules/ConversationClosingReport.js +2 -2
- package/src/import-map.mjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.13.3
|
|
4
14
|
|
|
5
15
|
- Skip automatic TTS finalization calls when a model stream completes while
|
|
@@ -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
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -320,6 +320,68 @@ Ordinary iteration exposes only text/reasoning chunks; raw structural deltas
|
|
|
320
320
|
remain internal until the complete terminal result validates. Explicit
|
|
321
321
|
`onResponse` or inspection consumers retain the complete raw terminal response.
|
|
322
322
|
|
|
323
|
+
## Stream selected tool text
|
|
324
|
+
|
|
325
|
+
Both `AI.streamRequest()` and this browser controller's `streamRequest()` accept
|
|
326
|
+
`toolText:{name,field}` with `onToolText(text,call,displayId)`. Select the exact
|
|
327
|
+
tool name and one root-level string argument. The callback receives newly
|
|
328
|
+
decoded text in arrival order, preserving whitespace and JSON string escapes as
|
|
329
|
+
their original characters. Its `call` record is
|
|
330
|
+
`{id,name,field,index,choiceIndex}`: the actual normalized tool-call ID, matching
|
|
331
|
+
tool name, selected field, call index within the response choice, and choice
|
|
332
|
+
index. Delivery waits until the matching name and ID are known. `displayId` is
|
|
333
|
+
the request's existing `M-${id}` display ID.
|
|
334
|
+
|
|
335
|
+
This callback is distinct from ordinary `onChunk`; display the text in the
|
|
336
|
+
corresponding tool turn and keep ordinary prose in its own existing turn.
|
|
337
|
+
`onToolCall` still receives only the complete normalized call after terminal
|
|
338
|
+
settlement. The text callback does not execute a tool, rewrite arguments, or
|
|
339
|
+
retain a transcript. The application owns the completed call's execution and
|
|
340
|
+
stores its complete visible result once through its normal history path.
|
|
341
|
+
If a provider supplies arguments only at completion, the callback runs only
|
|
342
|
+
when that actual complete response arrives. Repeated terminal snapshots do not
|
|
343
|
+
replay text already emitted. Callback errors reach the request owner, and a
|
|
344
|
+
cancelled or superseded request stops further text delivery.
|
|
345
|
+
|
|
346
|
+
For example, an application that already supplies a closing-report tool can
|
|
347
|
+
connect its existing display operations without parsing provider protocol:
|
|
348
|
+
|
|
349
|
+
```javascript
|
|
350
|
+
import {
|
|
351
|
+
formatConversationClosingReportText
|
|
352
|
+
} from '/arcane/modules/ConversationClosingReport.js';
|
|
353
|
+
|
|
354
|
+
async function streamClosingReport(ai, messages, closingTool, view, signal) {
|
|
355
|
+
return ai.streamRequest(
|
|
356
|
+
{
|
|
357
|
+
messages,
|
|
358
|
+
tools:[closingTool],
|
|
359
|
+
signal,
|
|
360
|
+
toolText:{name:closingTool.function.name, field:'final_message'},
|
|
361
|
+
onChunk:function appendOrdinaryText(text, displayId, thinking) {
|
|
362
|
+
return view.appendAssistantText(text, displayId, thinking);
|
|
363
|
+
},
|
|
364
|
+
onToolText:function appendClosingText(text, call, displayId) {
|
|
365
|
+
return view.appendToolText(
|
|
366
|
+
formatConversationClosingReportText(text),
|
|
367
|
+
call.id,
|
|
368
|
+
displayId
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The application supplies `view` and the existing `closingTool`; those are not
|
|
377
|
+
SDK exports. The formatter applies the same existing `&`, `<`, and `>` escaping
|
|
378
|
+
used for the complete closing report, including whitespace-only chunks. The
|
|
379
|
+
returned terminal call remains available to the application's normal tool
|
|
380
|
+
execution and persistence path. These options do not change the tool schema,
|
|
381
|
+
tool choice, or authored request content. Omitting `toolText` creates no text
|
|
382
|
+
observer. Invalid selection or callback input throws `TypeError`; malformed
|
|
383
|
+
selected argument text reports `ARCANE_AI_TOOL_TEXT_INVALID`.
|
|
384
|
+
|
|
323
385
|
## Errors and unavailable states
|
|
324
386
|
|
|
325
387
|
Invalid configuration can throw `TypeError` or `RangeError`. Operational
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"minimumVersion": "22.23.2 for Node entrypoints",
|
|
7
7
|
"moduleSystem": "ESM"
|
|
8
8
|
},
|
|
9
|
-
"memberCount":
|
|
9
|
+
"memberCount": 209,
|
|
10
10
|
"members": [
|
|
11
11
|
{
|
|
12
12
|
"id": "root:APP_BUNDLE_DESCRIPTOR_NAME",
|
|
@@ -2902,7 +2902,23 @@
|
|
|
2902
2902
|
"summary": "Creates the provider-neutral browser AI API module and LLM lifecycle controller around one compatible provider or controller.",
|
|
2903
2903
|
"availability": "Browser; the selected provider observes its own WebAssembly, storage, and model readiness",
|
|
2904
2904
|
"protocol": "arcane-ai-adapter/1",
|
|
2905
|
-
"normalization": "Normalizes lazy or manual load, mutable complete lifecycle status, cancellation, complete all-choice nonstructural stream projection, complete
|
|
2905
|
+
"normalization": "Normalizes lazy or manual load, mutable complete lifecycle status, cancellation, complete all-choice nonstructural stream projection, provider chunks with private structural fields removed, complete terminal data callbacks, optional toolText:{name,field} and distinct onToolText(text,call,displayId) delivery of selected decoded argument text, single-choice text or multi-choice terminal output, required structural arguments.message, and atomic exact matching nonblank all-ID tool-result sequencing without executing tools; an existing ModelController retains its preconfigured load policy and rejects a new security option"
|
|
2906
|
+
},
|
|
2907
|
+
{
|
|
2908
|
+
"id": "tool-text-stream:createToolTextObserver",
|
|
2909
|
+
"name": "createToolTextObserver",
|
|
2910
|
+
"displayName": "createToolTextObserver()",
|
|
2911
|
+
"kind": "function",
|
|
2912
|
+
"signature": "createToolTextObserver(selection, onText, {signal}={})",
|
|
2913
|
+
"entrypoints": [
|
|
2914
|
+
"arcane-os/ai/tool-text-stream"
|
|
2915
|
+
],
|
|
2916
|
+
"primaryImport": "arcane-os/ai/tool-text-stream",
|
|
2917
|
+
"group": "AI provider integration",
|
|
2918
|
+
"summary": "Observes one selected root string argument from actual tool-call chunks before provider structural filtering.",
|
|
2919
|
+
"availability": "Compatible JavaScript module host; no browser capability required",
|
|
2920
|
+
"protocol": "OpenAI-compatible tool-call deltas and complete message snapshots",
|
|
2921
|
+
"normalization": "Returns null for absent selection or an async chunk observer; preserves decoded text and whitespace, emits actual normalized call identity metadata, observes each raw delta once, avoids replay from complete snapshots, awaits callbacks, and stops later observation after cancellation without altering payloads, executing tools, or persisting history"
|
|
2906
2922
|
},
|
|
2907
2923
|
{
|
|
2908
2924
|
"id": "browser-wasm:createBrowserModelSource",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"artifactCount": 85,
|
|
12
12
|
"javascriptArtifactCount": 83,
|
|
13
|
-
"esmExportCount":
|
|
13
|
+
"esmExportCount": 353,
|
|
14
14
|
"artifacts": [
|
|
15
15
|
{
|
|
16
16
|
"file": "runtime/arcane/modules/AI.js",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"availability": "Browser + native bridge + TWiN Cloud",
|
|
31
31
|
"protocol": "arcane-ai-browser-speech-configuration/1, AIProviderRuntime arcane-ai-provider/2 routes, globalThis.arcaneEvents, TWiN Cloud HTTPS, Arcane.ollama, Arcane.speech, Android WebView bridge",
|
|
32
32
|
"normalization": "Complete mutable caller-owned browser speech authority, SDK-owned provider registration/replacement/disposal, explicit STT/TTS activation, normalized Whisper STT and Kokoro TTS execution with default capacities 1 and 4 respectively, explicit provider-neutral execution snapshots through status(role,{execution:true}), sticky readiness, canonical window.user readiness without compatibility-event object-identity admission or polling, exact ordered structural calls with required arguments.message, atomic nonblank all-ID tool-result sequencing, complete all-choice streaming/data validation, native Ollama structural adaptation, complete-response then all validated tool callbacks then completion ordering, observed async callbacks, Blob/File transcription, automatic repeated-formatting-mark removal from cloned outbound TTS input while caller and visible content stays exact, immediate exact-segment synthesis, per-call voice/speed capture, optional final-segment pause on the existing audio clock, optional per-call terminal playback completion, detached preparation with complete semantic DBOPFS audio reuse and same-owner pending request sharing, independent prepared playback without a model load for cached audio, ordered audio-clock scheduling, playable audio, active-generation TTS operation-failure routing, mute, and cancellation are normalized; provider/model/runtime/voice policy remains caller-owned. Both speech roles default execution.device to auto: webnn-npu when navigator.ml.createContext is exposed, then webgpu when navigator.gpu is exposed, then wasm for CPU execution. Failed backend loads clean up the pool before trying fresh Workers with the same prepared model and dtype; explicit devices never fall back. STT capacity only accepts 1; TTS accepts 1 through 4. Execution snapshots expose requestedDevice, selectedDevice, maxConcurrentRequests, and activeRequestCount for both roles; selectedDevice is null while unloaded and names the successful upstream session backend request when loaded. It does not prove every operation used the physical NPU: WebNN unsupported operations may use WASM, and compatibility depends on the browser, driver, hardware, and graph.",
|
|
33
|
-
"surface": "Browser-speech protocol/event/error/reason constants; default `AI`; read-only `providerRuntime`, `browserSpeechConfiguration`, and `browserSpeechDescriptor`; explicit `providerRuntime.status(role,{execution:true})` snapshots; `streamTTS(text,end,options={})` with optional voice, speed, pauseAfterMs, and waitForPlayback; automatic speech-input cleanup with SDK-internal preparation metadata; `prepareTTS({parts,storage,identity,signal,onState})` returning ordered segments, state, ready, getAudio(index), and cancel(); `playPreparedTTS(prepared,{signal,onState})` returning state, error, finished, pause(), resume(), and stop() with one playback lane per AI and observational state/error callbacks; configure/dispose speech, route lifecycle, declaration-validated chat/stream requests with exact ordered calls, synthesis/transcription
|
|
33
|
+
"surface": "Browser-speech protocol/event/error/reason constants; default `AI`; read-only `providerRuntime`, `browserSpeechConfiguration`, and `browserSpeechDescriptor`; explicit `providerRuntime.status(role,{execution:true})` snapshots; `streamTTS(text,end,options={})` with optional voice, speed, pauseAfterMs, and waitForPlayback; automatic speech-input cleanup with SDK-internal preparation metadata; `prepareTTS({parts,storage,identity,signal,onState})` returning ordered segments, state, ready, getAudio(index), and cancel(); `playPreparedTTS(prepared,{signal,onState})` returning state, error, finished, pause(), resume(), and stop() with one playback lane per AI and observational state/error callbacks; configure/dispose speech, route lifecycle, declaration-validated chat/stream requests with exact ordered calls; optional streamRequest toolText:{name,field} and onToolText(text,call,displayId) for decoded selected argument text with actual call identity, distinct from ordinary prose and terminal tool execution; synthesis/transcription and playback controls; initializes from current canonical user readiness, installs `window.ai`, and projects `ai-ready` plus active-generation `ai-tts-failure`."
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"file": "runtime/arcane/modules/AIPreferenceRuntime.js",
|
|
@@ -456,13 +456,14 @@
|
|
|
456
456
|
"conversationClosingReportInstruction",
|
|
457
457
|
"createConversationClosingReportTool",
|
|
458
458
|
"formatConversationClosingReport",
|
|
459
|
+
"formatConversationClosingReportText",
|
|
459
460
|
"normalizeConversationClosingReport"
|
|
460
461
|
],
|
|
461
462
|
"summary": "Defines the closing-report tool, instruction, result normalizer, call classifier, and formatter.",
|
|
462
463
|
"availability": "Cross-host",
|
|
463
464
|
"protocol": "In-process only",
|
|
464
465
|
"normalization": "Required user-facing message remains distinct from the complete final_message; normalized results expose message, finalMessage, and rememberedActions.",
|
|
465
|
-
"surface": "
|
|
466
|
+
"surface": "Seven constants/helpers for closing reports; the sole-call schema requires message and final_message. formatConversationClosingReportText(value) exposes the existing &, <, and > escaping for any string, including whitespace-only chunks, and the complete report formatter reuses it."
|
|
466
467
|
},
|
|
467
468
|
{
|
|
468
469
|
"file": "runtime/arcane/modules/ConversationTimebox.js",
|
|
@@ -432,7 +432,7 @@ declined, cancelled, or not-executed result.
|
|
|
432
432
|
That tool-result content must be a nonblank string and is preserved exactly.
|
|
433
433
|
|
|
434
434
|
`streamRequest()` owns the complete terminal callback sequence. `onDataChunk`
|
|
435
|
-
receives each
|
|
435
|
+
receives each provider chunk after private structural fields are removed, while
|
|
436
436
|
`onChunk` receives every nonstructural content or reasoning value from every
|
|
437
437
|
choice in provider order. After the stream settles, `onDataResult` receives the
|
|
438
438
|
complete terminal completion, `onResponse` receives that same unprojected
|
|
@@ -442,8 +442,29 @@ output is the ordered structural-call array when the selected result contains
|
|
|
442
442
|
tools, the complete completion object when it contains multiple choices, or
|
|
443
443
|
the ordinary single-result text/completion otherwise; later choices are never
|
|
444
444
|
discarded.
|
|
445
|
-
|
|
446
|
-
validates.
|
|
445
|
+
Raw structural deltas remain private until the matching terminal envelope
|
|
446
|
+
validates. To display one tool argument's text as it arrives, supply
|
|
447
|
+
`toolText:{name,field}` and `onToolText(text,call,displayId)`. `name` is the exact
|
|
448
|
+
tool name and `field` is one root-level string argument. The callback receives
|
|
449
|
+
each newly decoded text fragment in order, including whitespace and JSON string
|
|
450
|
+
escapes decoded to their original characters. Its `call` record contains
|
|
451
|
+
`{id,name,field,index,choiceIndex}`; `id` is the actual normalized tool-call ID,
|
|
452
|
+
`index` identifies the call within its choice, and `choiceIndex` identifies the
|
|
453
|
+
response choice. Delivery starts once the matching tool name and call ID are
|
|
454
|
+
known. `displayId` is the same `M-${id}` request display ID used by `onChunk`.
|
|
455
|
+
|
|
456
|
+
`onToolText` is separate from ordinary `onChunk`, so callers can append to the
|
|
457
|
+
tool's existing display without duplicating assistant prose or saved history.
|
|
458
|
+
It observes text only: it does not execute a tool, alter arguments, persist a
|
|
459
|
+
turn, or change the terminal callbacks. A provider that supplies arguments only
|
|
460
|
+
at completion emits text only when that complete response arrives. Repeated
|
|
461
|
+
terminal snapshots do not replay text already emitted. Omitting `toolText`
|
|
462
|
+
preserves the existing callback path. Invalid selection or callback input throws
|
|
463
|
+
`TypeError`; malformed selected argument text reports
|
|
464
|
+
`ARCANE_AI_TOOL_TEXT_INVALID`. Callback errors reach the request owner and
|
|
465
|
+
cancellation prevents later delivery.
|
|
466
|
+
|
|
467
|
+
Request observers receive
|
|
447
468
|
`onRequest(request,id,metadata)` and any transport metadata supplied by the
|
|
448
469
|
selected route is forwarded unchanged. Every async native, HTTP, provider, and
|
|
449
470
|
built-in callback is observed before the next callback or terminal settlement.
|
|
@@ -1609,7 +1630,7 @@ Defines the closing-report tool, instruction, result normalizer, call classifier
|
|
|
1609
1630
|
|
|
1610
1631
|
### Public surface
|
|
1611
1632
|
|
|
1612
|
-
|
|
1633
|
+
Seven constants/helpers for closing reports.
|
|
1613
1634
|
|
|
1614
1635
|
The generated sole-call schema requires both `message` and `final_message`.
|
|
1615
1636
|
`message` is brief user-facing progress shown while the application accepts and
|
|
@@ -1617,9 +1638,14 @@ renders the call. `final_message` remains the complete terminal closeout and is
|
|
|
1617
1638
|
never replaced by or duplicated into `message`; `remembered_actions` remains
|
|
1618
1639
|
optional. `normalizeConversationClosingReport()` returns
|
|
1619
1640
|
`{message,finalMessage,rememberedActions}`, while
|
|
1620
|
-
`formatConversationClosingReport()`
|
|
1641
|
+
`formatConversationClosingReport()` normalizes the complete report and formats
|
|
1642
|
+
only `finalMessage`. Its shared `formatConversationClosingReportText(value)`
|
|
1643
|
+
operation accepts a string, including an empty or whitespace-only chunk, and
|
|
1644
|
+
replaces `&`, `<`, and `>` with `&`, `<`, and `>` respectively. It
|
|
1645
|
+
preserves all other text and does not trim, validate a report, render, or persist
|
|
1646
|
+
anything. Use the same operation for live text chunks and complete final text.
|
|
1621
1647
|
|
|
1622
|
-
Exact exports: `CONVERSATION_CLOSING_REPORT_TOOL_NAME`, `classifyConversationClosingReportCalls`, `conversationClosingReportInstruction`, `createConversationClosingReportTool`, `formatConversationClosingReport`, `normalizeConversationClosingReport`.
|
|
1648
|
+
Exact exports: `CONVERSATION_CLOSING_REPORT_TOOL_NAME`, `classifyConversationClosingReportCalls`, `conversationClosingReportInstruction`, `createConversationClosingReportTool`, `formatConversationClosingReport`, `formatConversationClosingReportText`, `normalizeConversationClosingReport`.
|
|
1623
1649
|
|
|
1624
1650
|
### Availability and normalization
|
|
1625
1651
|
|
|
@@ -1628,9 +1654,11 @@ Exact exports: `CONVERSATION_CLOSING_REPORT_TOOL_NAME`, `classifyConversationClo
|
|
|
1628
1654
|
### Example
|
|
1629
1655
|
|
|
1630
1656
|
```javascript
|
|
1631
|
-
import
|
|
1657
|
+
import {
|
|
1658
|
+
formatConversationClosingReportText
|
|
1659
|
+
} from '/arcane/modules/ConversationClosingReport.js';
|
|
1632
1660
|
|
|
1633
|
-
console.log(
|
|
1661
|
+
console.log(formatConversationClosingReportText('Complete <draft> & next step.'));
|
|
1634
1662
|
```
|
|
1635
1663
|
|
|
1636
1664
|
## ConversationTimebox.js
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The npm package exposes a Node.js ESM control plane, the portable
|
|
4
4
|
`arcane-os/event-manager`, `arcane-os/logging`, `arcane-os/mail`,
|
|
5
5
|
`arcane-os/preference-store`, `arcane-os/speech-playback`,
|
|
6
|
-
`arcane-os/speech-text`, and `arcane-os/browser-device` entrypoints, and the browser-only
|
|
6
|
+
`arcane-os/speech-text`, `arcane-os/ai/tool-text-stream`, and `arcane-os/browser-device` entrypoints, and the browser-only
|
|
7
7
|
`arcane-os/pwa`, `arcane-os/ai/browser-wasm` and `arcane-os/ai/browser-speech` entrypoints.
|
|
8
8
|
Those package subpaths are distinct from application-facing projection modules
|
|
9
9
|
in the managed browser map, such as `arcane/AIProviderRuntime`,
|
|
@@ -41,6 +41,7 @@ for the installed-inventory-derived physical-runtime contract in SDK `0.5.18`.
|
|
|
41
41
|
| `arcane-os/browser-device` | Synchronous mobile or desktop identity hints for application-owned settings. |
|
|
42
42
|
| `arcane-os/pwa` | Nonblocking PWA registration, worker updates, native installation state and a dismissible installation component. |
|
|
43
43
|
| `arcane-os/ai/browser-wasm` | Caller-selected browser-local Wllama inference, complete DBOPFS model storage, streaming, cancellation, and structural tool-call results. |
|
|
44
|
+
| `arcane-os/ai/tool-text-stream` | Shared selected tool-argument text observer for provider integration. |
|
|
44
45
|
| `arcane-os/ai/browser-speech` | Caller-selected browser-local Whisper STT and Kokoro TTS provider mechanisms, ordinary upstream assets, materialized/native routing, Workers, and cancellation. |
|
|
45
46
|
| `arcane-os/mail` | Portable Mail runtime, durable outbox, complete transport responses, and provider-neutral acceptance contracts. |
|
|
46
47
|
|
|
@@ -138,6 +139,7 @@ browser map are cataloged separately in [Runtime modules](runtime-modules.md).
|
|
|
138
139
|
| `createNativeTargetAdapter()` | function | `arcane-os` | Targets, native plans, and providers | Node; selected browser/native target or provider as documented |
|
|
139
140
|
| `createReporter()` | function | `arcane-os` | Events, processes, and testing | Node |
|
|
140
141
|
| `createToolchain()` | function | `arcane-os` | Headless toolchain operations | Node; selected operation may produce browser or native output |
|
|
142
|
+
| `createToolTextObserver()` | function | `arcane-os/ai/tool-text-stream` | AI provider integration | Compatible JavaScript module host; no browser capability required |
|
|
141
143
|
| `createWorkspace()` | function | `arcane-os` | Workspace, doctor, repository, and server | Node |
|
|
142
144
|
| `default()` | function | `arcane-os/testing` | Events, processes, and testing | Node |
|
|
143
145
|
| `DEFAULT_TEST_TIMEOUT_MS` | constant | `arcane-os` | Events, processes, and testing | Node |
|
|
@@ -5905,8 +5907,20 @@ At least one `llm` or `provider` is required. The mutable API object contains
|
|
|
5905
5907
|
delivers every choice's ordinary content/reasoning values in provider order,
|
|
5906
5908
|
and returns ordinary terminal text for one choice, a structural-call array for
|
|
5907
5909
|
selected tool output, or complete JSON text for a multi-choice completion.
|
|
5908
|
-
`onDataChunk`
|
|
5909
|
-
terminal record independently of that
|
|
5910
|
+
`onDataChunk` receives provider chunks with private structural fields removed;
|
|
5911
|
+
`onDataResult` preserves the complete terminal record independently of that
|
|
5912
|
+
application-facing projection. Supply `toolText:{name,field}` with
|
|
5913
|
+
`onToolText(text,call,displayId)` to receive one selected root string argument
|
|
5914
|
+
as it arrives. `call` contains `{id,name,field,index,choiceIndex}` with the real
|
|
5915
|
+
normalized call ID, call position, and response-choice index. The SDK decodes
|
|
5916
|
+
JSON string escapes, preserves whitespace and order, and starts delivery once
|
|
5917
|
+
the matching tool name and call ID are known. `displayId` is the same request
|
|
5918
|
+
display ID as `onChunk`. This separate callback does not add ordinary prose,
|
|
5919
|
+
execute tools, or persist history. Complete-only providers emit the selected
|
|
5920
|
+
text only on actual completion; repeated terminal snapshots do not replay
|
|
5921
|
+
emitted text. Callback failures reach the request owner and cancellation
|
|
5922
|
+
prevents later delivery. Omitting `toolText` preserves the existing behavior.
|
|
5923
|
+
See the [tool-text example](ai/browser-wasm.md#stream-selected-tool-text). Use
|
|
5910
5924
|
`ai.llm.stream()` for the async iterator; structural fragments remain private
|
|
5911
5925
|
until the complete terminal result validates.
|
|
5912
5926
|
|
|
@@ -5991,6 +6005,61 @@ async function openLocalReviewAfterUserChoice() {
|
|
|
5991
6005
|
}
|
|
5992
6006
|
```
|
|
5993
6007
|
|
|
6008
|
+
## createToolTextObserver()
|
|
6009
|
+
|
|
6010
|
+
### Overview
|
|
6011
|
+
|
|
6012
|
+
Creates the shared tool-text observer used by SDK provider integrations before
|
|
6013
|
+
private structural fields are removed. Application callers normally use
|
|
6014
|
+
`streamRequest({toolText,onToolText})` instead.
|
|
6015
|
+
|
|
6016
|
+
### Signature and result
|
|
6017
|
+
|
|
6018
|
+
```text
|
|
6019
|
+
createToolTextObserver(selection, onText, {signal}={})
|
|
6020
|
+
```
|
|
6021
|
+
|
|
6022
|
+
`selection` is `{name,field}`: the exact tool name and one root string argument.
|
|
6023
|
+
`undefined`, `null`, or `false` selection returns `null`. An active selection
|
|
6024
|
+
requires nonblank string `name` and `field` values and an `onText` function;
|
|
6025
|
+
invalid configuration throws `TypeError`.
|
|
6026
|
+
|
|
6027
|
+
The result is an async `observeToolText(chunk)` function. It accepts raw
|
|
6028
|
+
OpenAI-compatible `choices[].delta.tool_calls` fragments and complete
|
|
6029
|
+
`choices[].message.tool_calls` or `message.tool_calls` snapshots. `onText` is
|
|
6030
|
+
awaited with each newly decoded fragment and
|
|
6031
|
+
`{id,name,field,index,choiceIndex}`. It preserves decoded string content and
|
|
6032
|
+
whitespace, waits for the matching name and real normalized ID, and reports
|
|
6033
|
+
malformed selected argument text with `ARCANE_AI_TOOL_TEXT_INVALID`.
|
|
6034
|
+
|
|
6035
|
+
### Availability and normalization
|
|
6036
|
+
|
|
6037
|
+
**Compatible JavaScript module host.** One observer owns one request's call
|
|
6038
|
+
state. Consume each raw delta once, before any structural filtering; pass the
|
|
6039
|
+
same observer through the provider integration instead of creating another
|
|
6040
|
+
parser. Complete snapshots may be observed again without replaying emitted
|
|
6041
|
+
text. A complete-only response emits only when received. `signal` stops later
|
|
6042
|
+
observation after cancellation; callback errors propagate to the caller. The
|
|
6043
|
+
observer does not modify the supplied chunk, invoke tools, persist history, or
|
|
6044
|
+
start inference.
|
|
6045
|
+
|
|
6046
|
+
### Example
|
|
6047
|
+
|
|
6048
|
+
```javascript
|
|
6049
|
+
import {createToolTextObserver} from 'arcane-os/ai/tool-text-stream';
|
|
6050
|
+
|
|
6051
|
+
function createProviderTextObserver(onText, signal) {
|
|
6052
|
+
return createToolTextObserver(
|
|
6053
|
+
{name:'prepare_conversation_closing_report', field:'final_message'},
|
|
6054
|
+
onText,
|
|
6055
|
+
{signal}
|
|
6056
|
+
);
|
|
6057
|
+
}
|
|
6058
|
+
```
|
|
6059
|
+
|
|
6060
|
+
The provider owner passes actual raw chunks to the returned observer and awaits
|
|
6061
|
+
it before continuing its existing filtered stream delivery.
|
|
6062
|
+
|
|
5994
6063
|
## createBrowserModelSource()
|
|
5995
6064
|
|
|
5996
6065
|
### Overview
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arcane-os",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./speech-playback": "./runtime/arcane/modules/SpeechPlayback.js",
|
|
21
21
|
"./speech-text": "./browser-runtime/speech-text.mjs",
|
|
22
22
|
"./ai/browser-wasm": "./browser-runtime/ai/browser-wasm.mjs",
|
|
23
|
+
"./ai/tool-text-stream": "./browser-runtime/ai/tool-text-stream.mjs",
|
|
23
24
|
"./ai/browser-speech": "./browser-runtime/ai/browser-speech.mjs",
|
|
24
25
|
"./mail": "./src/mail-api.mjs",
|
|
25
26
|
"./testing": "./src/testing.mjs",
|
|
@@ -18,6 +18,7 @@ import {normalizeOllamaModelIdentifier} from './OllamaModelIdentifier.js';
|
|
|
18
18
|
import {arcaneLogging} from 'arcane-os/logging';
|
|
19
19
|
import {MarkdownSpeech,stripSpeechFormatting} from 'arcane-os/speech-text';
|
|
20
20
|
import {prepareSpeech} from './PreparedSpeech.js';
|
|
21
|
+
import {createToolTextObserver} from 'arcane-os/ai/tool-text-stream';
|
|
21
22
|
|
|
22
23
|
const completeValue=(value)=>value;
|
|
23
24
|
|
|
@@ -1470,7 +1471,7 @@ class AI {
|
|
|
1470
1471
|
status:statusBuiltInLLMProvider()
|
|
1471
1472
|
});
|
|
1472
1473
|
},
|
|
1473
|
-
request:function requestBuiltInLLMProvider(context={}){
|
|
1474
|
+
request:function requestBuiltInLLMProvider(context={},controls={}){
|
|
1474
1475
|
if(context.signal?.aborted){
|
|
1475
1476
|
throw normalizeAIRequestAbort(context.signal.reason);
|
|
1476
1477
|
}
|
|
@@ -1502,7 +1503,8 @@ class AI {
|
|
|
1502
1503
|
function executeBuiltInLLMProviderStream(bridge){
|
|
1503
1504
|
return runtime.#requestBuiltInLLMStream(
|
|
1504
1505
|
context.payload,
|
|
1505
|
-
bridge
|
|
1506
|
+
bridge,
|
|
1507
|
+
controls.observeToolText
|
|
1506
1508
|
);
|
|
1507
1509
|
},
|
|
1508
1510
|
context.signal
|
|
@@ -4285,7 +4287,7 @@ class AI {
|
|
|
4285
4287
|
);
|
|
4286
4288
|
}
|
|
4287
4289
|
|
|
4288
|
-
#requestBuiltInLLMStream(payload={},bridge){
|
|
4290
|
+
#requestBuiltInLLMStream(payload={},bridge,observeToolText=null){
|
|
4289
4291
|
const parallelToolCalls=payload.parallelToolCalls!==undefined
|
|
4290
4292
|
?payload.parallelToolCalls
|
|
4291
4293
|
:payload.parallel_tool_calls;
|
|
@@ -4310,7 +4312,8 @@ class AI {
|
|
|
4310
4312
|
true,
|
|
4311
4313
|
emitBuiltInLLMStreamData,
|
|
4312
4314
|
function ignoreBuiltInLLMStreamResult(){},
|
|
4313
|
-
payload.reasoningEffort
|
|
4315
|
+
payload.reasoningEffort,
|
|
4316
|
+
observeToolText
|
|
4314
4317
|
);
|
|
4315
4318
|
}
|
|
4316
4319
|
|
|
@@ -4459,6 +4462,8 @@ class AI {
|
|
|
4459
4462
|
structuredOutput=false,
|
|
4460
4463
|
localOnly=false,
|
|
4461
4464
|
onChunk=function ignoreStreamChunk(){},
|
|
4465
|
+
toolText,
|
|
4466
|
+
onToolText,
|
|
4462
4467
|
onComplete=function finishIgnoredStream(){},
|
|
4463
4468
|
onDataChunk=function ignoreStreamDataChunk(){},
|
|
4464
4469
|
onDataResult=function ignoreStreamDataResult(){},
|
|
@@ -4484,6 +4489,15 @@ class AI {
|
|
|
4484
4489
|
reasoningEffort
|
|
4485
4490
|
}={}){
|
|
4486
4491
|
validateAIStructuralRequest(messages,tools,parallelToolCalls);
|
|
4492
|
+
const observeToolText=createToolTextObserver(
|
|
4493
|
+
toolText,
|
|
4494
|
+
is.function(onToolText)
|
|
4495
|
+
?function reportAIStreamToolText(text,call){
|
|
4496
|
+
return onToolText(text,call,`M-${id}`);
|
|
4497
|
+
}
|
|
4498
|
+
:onToolText,
|
|
4499
|
+
{signal}
|
|
4500
|
+
);
|
|
4487
4501
|
const normalizedReasoningEffort=normalizeAIReasoningEffort(
|
|
4488
4502
|
reasoningEffort===undefined?this.reasoningEffort:reasoningEffort
|
|
4489
4503
|
);
|
|
@@ -4538,7 +4552,8 @@ class AI {
|
|
|
4538
4552
|
payload:request,
|
|
4539
4553
|
localOnly,
|
|
4540
4554
|
signal
|
|
4541
|
-
}
|
|
4555
|
+
},
|
|
4556
|
+
{observeToolText}
|
|
4542
4557
|
);
|
|
4543
4558
|
for await(const chunk of handle){
|
|
4544
4559
|
if(signal?.aborted){
|
|
@@ -4622,7 +4637,8 @@ class AI {
|
|
|
4622
4637
|
true,
|
|
4623
4638
|
onDataChunk,
|
|
4624
4639
|
onDataResult,
|
|
4625
|
-
normalizedReasoningEffort
|
|
4640
|
+
normalizedReasoningEffort,
|
|
4641
|
+
observeToolText
|
|
4626
4642
|
);
|
|
4627
4643
|
const structuralToolCalls=normalizeAICompletionToolCalls(
|
|
4628
4644
|
completion
|
|
@@ -4729,7 +4745,8 @@ class AI {
|
|
|
4729
4745
|
returnCompletion=false,
|
|
4730
4746
|
dataChunkHandler=function ignoreBuiltInStreamDataChunk(){},
|
|
4731
4747
|
dataResultHandler=function ignoreBuiltInStreamDataResult(){},
|
|
4732
|
-
reasoningEffort
|
|
4748
|
+
reasoningEffort,
|
|
4749
|
+
observeToolText=null
|
|
4733
4750
|
){
|
|
4734
4751
|
let speechTurnCompleted=false;
|
|
4735
4752
|
|
|
@@ -4845,6 +4862,12 @@ class AI {
|
|
|
4845
4862
|
);
|
|
4846
4863
|
}
|
|
4847
4864
|
streamedNativeToolCalls=observed;
|
|
4865
|
+
if(observeToolText){
|
|
4866
|
+
await observeToolText({message:{tool_calls:observed}});
|
|
4867
|
+
}
|
|
4868
|
+
}
|
|
4869
|
+
if(signal?.aborted){
|
|
4870
|
+
return;
|
|
4848
4871
|
}
|
|
4849
4872
|
const thinking=seeThinking
|
|
4850
4873
|
?String(message.thinking||'')
|
|
@@ -4918,6 +4941,12 @@ class AI {
|
|
|
4918
4941
|
structuralToolCalls,
|
|
4919
4942
|
tool_choice
|
|
4920
4943
|
);
|
|
4944
|
+
if(observeToolText){
|
|
4945
|
+
await observeToolText(nativeCompletion);
|
|
4946
|
+
}
|
|
4947
|
+
if(signal?.aborted){
|
|
4948
|
+
throw normalizeAIRequestAbort();
|
|
4949
|
+
}
|
|
4921
4950
|
await dataResultHandler(nativeCompletion,id);
|
|
4922
4951
|
for(const call of structuralToolCalls){
|
|
4923
4952
|
if(signal?.aborted){
|
|
@@ -5096,6 +5125,12 @@ class AI {
|
|
|
5096
5125
|
'The AI stream returned a non-object event payload.'
|
|
5097
5126
|
);
|
|
5098
5127
|
}
|
|
5128
|
+
if(observeToolText){
|
|
5129
|
+
await observeToolText(streamedResponse);
|
|
5130
|
+
}
|
|
5131
|
+
if(signal?.aborted){
|
|
5132
|
+
throw normalizeAIRequestAbort();
|
|
5133
|
+
}
|
|
5099
5134
|
const dataChunk=projectAIStreamChunk(streamedResponse);
|
|
5100
5135
|
if(dataChunk!==OMITTED_AI_STREAM_DATA){
|
|
5101
5136
|
await dataChunkHandler(dataChunk,id);
|
|
@@ -5253,6 +5288,13 @@ class AI {
|
|
|
5253
5288
|
await drainSseBuffer(true);
|
|
5254
5289
|
}
|
|
5255
5290
|
}catch(error){
|
|
5291
|
+
if(observeToolText){
|
|
5292
|
+
await reader.cancel(error).catch(
|
|
5293
|
+
function reportToolTextReaderCleanupFailure(cleanupError){
|
|
5294
|
+
arcaneLogging.error('Arcane tool text reader cleanup failed.',cleanupError);
|
|
5295
|
+
}
|
|
5296
|
+
);
|
|
5297
|
+
}
|
|
5256
5298
|
if(isAIRequestAbort(error,signal)){
|
|
5257
5299
|
throw normalizeAIRequestAbort(error);
|
|
5258
5300
|
}
|
|
@@ -5383,6 +5425,12 @@ class AI {
|
|
|
5383
5425
|
terminalToolCalls,
|
|
5384
5426
|
'The built-in HTTP stream'
|
|
5385
5427
|
);
|
|
5428
|
+
if(observeToolText){
|
|
5429
|
+
await observeToolText(completion);
|
|
5430
|
+
}
|
|
5431
|
+
if(signal?.aborted){
|
|
5432
|
+
throw normalizeAIRequestAbort();
|
|
5433
|
+
}
|
|
5386
5434
|
await dataResultHandler(completion,id);
|
|
5387
5435
|
for(const call of terminalToolCalls){
|
|
5388
5436
|
if(signal?.aborted){
|
|
@@ -3347,14 +3347,14 @@ export class AIProviderRuntime {
|
|
|
3347
3347
|
return this.#disposeAllPromise;
|
|
3348
3348
|
}
|
|
3349
3349
|
|
|
3350
|
-
request(role, options = {}, {speechInputPrepared = false} = {}) {
|
|
3350
|
+
request(role, options = {}, {speechInputPrepared = false, observeToolText = null} = {}) {
|
|
3351
3351
|
if (SPEECH_ROLES.includes(role)) {
|
|
3352
3352
|
arcaneLogging.debug('[Arcane speech runtime] request', role, options);
|
|
3353
3353
|
}
|
|
3354
|
-
return this.#requestRole(role, options, false, speechInputPrepared);
|
|
3354
|
+
return this.#requestRole(role, options, false, speechInputPrepared, observeToolText);
|
|
3355
3355
|
}
|
|
3356
3356
|
|
|
3357
|
-
#requestRole(role, options, queued, speechInputPrepared = false) {
|
|
3357
|
+
#requestRole(role, options, queued, speechInputPrepared = false, observeToolText = null) {
|
|
3358
3358
|
this.#assertOpen();
|
|
3359
3359
|
this.#assertNotConfiguring();
|
|
3360
3360
|
assertRole(role);
|
|
@@ -3486,7 +3486,7 @@ export class AIProviderRuntime {
|
|
|
3486
3486
|
}
|
|
3487
3487
|
if ((!queued && slot.requestQueue.length)
|
|
3488
3488
|
|| slot.activeRequests.size >= maxConcurrentRequests) {
|
|
3489
|
-
return this.#enqueueRoleRequest(slot, options);
|
|
3489
|
+
return this.#enqueueRoleRequest(slot, options, observeToolText);
|
|
3490
3490
|
}
|
|
3491
3491
|
if (!slot.ready
|
|
3492
3492
|
|| providerStatus.state !== 'ready'
|
|
@@ -3556,6 +3556,17 @@ export class AIProviderRuntime {
|
|
|
3556
3556
|
}
|
|
3557
3557
|
|
|
3558
3558
|
if (options.operation === 'stream') {
|
|
3559
|
+
const observeCurrentToolText = role === 'llm' && observeToolText
|
|
3560
|
+
? async function observeCurrentAIProviderToolText(chunk) {
|
|
3561
|
+
runtime.#assertCurrentRequest(
|
|
3562
|
+
slot, generation, requestSequence, requestRecord, controller.signal
|
|
3563
|
+
);
|
|
3564
|
+
await observeToolText(chunk);
|
|
3565
|
+
runtime.#assertCurrentRequest(
|
|
3566
|
+
slot, generation, requestSequence, requestRecord, controller.signal
|
|
3567
|
+
);
|
|
3568
|
+
}
|
|
3569
|
+
: null;
|
|
3559
3570
|
let providerHandle = null;
|
|
3560
3571
|
let providerOpenPromise = null;
|
|
3561
3572
|
let iterator = null;
|
|
@@ -3796,7 +3807,8 @@ export class AIProviderRuntime {
|
|
|
3796
3807
|
operation: options.operation,
|
|
3797
3808
|
payload: options.payload,
|
|
3798
3809
|
signal: controller.signal
|
|
3799
|
-
}
|
|
3810
|
+
},
|
|
3811
|
+
{observeToolText: observeCurrentToolText}
|
|
3800
3812
|
);
|
|
3801
3813
|
}
|
|
3802
3814
|
);
|
|
@@ -3862,6 +3874,7 @@ export class AIProviderRuntime {
|
|
|
3862
3874
|
continue;
|
|
3863
3875
|
}
|
|
3864
3876
|
llmToolCallCorrelation.observe(result.value);
|
|
3877
|
+
if (observeCurrentToolText) await observeCurrentToolText(result.value);
|
|
3865
3878
|
const projected = projectLLMStreamChunk(result.value);
|
|
3866
3879
|
if (projected !== OMITTED_LLM_STREAM_DATA) {
|
|
3867
3880
|
publishProjectedAIProviderStreamChunk(projected);
|
|
@@ -3902,6 +3915,7 @@ export class AIProviderRuntime {
|
|
|
3902
3915
|
)
|
|
3903
3916
|
: value;
|
|
3904
3917
|
llmToolCallCorrelation?.assertTerminal(terminalValue);
|
|
3918
|
+
if (observeCurrentToolText) await observeCurrentToolText(terminalValue);
|
|
3905
3919
|
settleAIProviderStream(null, terminalValue);
|
|
3906
3920
|
} catch (error) {
|
|
3907
3921
|
if (cleanupPromise) {
|
|
@@ -4208,7 +4222,7 @@ export class AIProviderRuntime {
|
|
|
4208
4222
|
);
|
|
4209
4223
|
}
|
|
4210
4224
|
|
|
4211
|
-
#enqueueRoleRequest(slot, options) {
|
|
4225
|
+
#enqueueRoleRequest(slot, options, observeToolText = null) {
|
|
4212
4226
|
if (SPEECH_ROLES.includes(slot.role)) {
|
|
4213
4227
|
arcaneLogging.debug('[Arcane speech runtime] queue.enqueue', {
|
|
4214
4228
|
role: slot.role,
|
|
@@ -4222,6 +4236,7 @@ export class AIProviderRuntime {
|
|
|
4222
4236
|
return new Promise(function queueAIProviderRoleRequest(resolve, reject) {
|
|
4223
4237
|
const entry = {
|
|
4224
4238
|
options,
|
|
4239
|
+
observeToolText,
|
|
4225
4240
|
resolve,
|
|
4226
4241
|
reject,
|
|
4227
4242
|
detachSignal: null
|
|
@@ -4293,7 +4308,9 @@ export class AIProviderRuntime {
|
|
|
4293
4308
|
}
|
|
4294
4309
|
let operation;
|
|
4295
4310
|
try {
|
|
4296
|
-
operation = runtime.#requestRole(
|
|
4311
|
+
operation = runtime.#requestRole(
|
|
4312
|
+
slot.role, entry.options, true, false, entry.observeToolText
|
|
4313
|
+
);
|
|
4297
4314
|
} catch (error) {
|
|
4298
4315
|
if (SPEECH_ROLES.includes(slot.role)) {
|
|
4299
4316
|
arcaneLogging.debug('[Arcane speech runtime] queue.error', slot.role, entry.options, error);
|
|
@@ -74,7 +74,7 @@ function parseArguments(value){
|
|
|
74
74
|
return value;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
function
|
|
77
|
+
export function formatConversationClosingReportText(value){
|
|
78
78
|
return value
|
|
79
79
|
.replaceAll('&','&')
|
|
80
80
|
.replaceAll('<','<')
|
|
@@ -240,7 +240,7 @@ export function formatConversationClosingReport(value){
|
|
|
240
240
|
remembered_actions:value.rememberedActions,
|
|
241
241
|
}
|
|
242
242
|
:value;
|
|
243
|
-
return
|
|
243
|
+
return formatConversationClosingReportText(
|
|
244
244
|
normalizeConversationClosingReport(normalizedInput).finalMessage
|
|
245
245
|
);
|
|
246
246
|
}
|
package/src/import-map.mjs
CHANGED
|
@@ -27,6 +27,7 @@ const SDK_BROWSER_SELF_IMPORTS=new Map([
|
|
|
27
27
|
['arcane-os/pwa','sdk/pwa.mjs'],
|
|
28
28
|
['arcane-os/speech-text','sdk/speech-text.mjs'],
|
|
29
29
|
['arcane-os/ai/browser-wasm',SDK_BROWSER_AI_ENTRY],
|
|
30
|
+
['arcane-os/ai/tool-text-stream','sdk/ai/tool-text-stream.mjs'],
|
|
30
31
|
['arcane-os/ai/browser-speech',SDK_BROWSER_SPEECH_ENTRY]
|
|
31
32
|
]);
|
|
32
33
|
function fail(message,code='ARCANE_IMPORT_MAP_INVALID'){
|