@genesislcap/ai-assistant 14.452.1 → 14.454.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/dist/ai-assistant.api.json +221 -2
- package/dist/ai-assistant.d.ts +93 -1
- package/dist/dts/components/ai-driver/ai-driver.d.ts +13 -0
- package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +47 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +12 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +16 -0
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/main/main.types.d.ts +5 -1
- package/dist/dts/main/main.types.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +129 -1
- package/dist/esm/components/chat-driver/chat-driver.test.js +190 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +23 -0
- package/dist/esm/main/main.js +49 -11
- package/dist/esm/main/main.styles.js +26 -0
- package/dist/esm/main/main.template.js +27 -11
- package/package.json +16 -16
- package/src/components/ai-driver/ai-driver.ts +15 -0
- package/src/components/chat-driver/chat-driver.test.ts +227 -0
- package/src/components/chat-driver/chat-driver.ts +136 -1
- package/src/components/orchestrating-driver/orchestrating-driver.ts +24 -0
- package/src/main/main.styles.ts +26 -0
- package/src/main/main.template.ts +30 -11
- package/src/main/main.ts +48 -11
- package/src/main/main.types.ts +5 -1
|
@@ -593,6 +593,32 @@ export const styles = css `
|
|
|
593
593
|
background-color: var(--neutral-layer-2);
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
/* Right-hand control column, mirroring .input-left-controls — holds the
|
|
597
|
+
Stop button (shown while a turn runs) stacked above the Send button. The
|
|
598
|
+
Stop button is an icon (stop → spinner), so the column width is driven by
|
|
599
|
+
Send and stays stable whether a turn is running or idle. */
|
|
600
|
+
.input-right-controls {
|
|
601
|
+
display: flex;
|
|
602
|
+
flex-direction: column;
|
|
603
|
+
align-items: stretch;
|
|
604
|
+
gap: calc(var(--design-unit) * 1px);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/* The "stopping" state shows a spinner — rotate it (the icon set's glyph is
|
|
608
|
+
static). Transform-only, so it never affects layout/width. */
|
|
609
|
+
@keyframes ai-stop-spin {
|
|
610
|
+
to {
|
|
611
|
+
transform: rotate(360deg);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.stop-icon-spinning {
|
|
616
|
+
/* inline-block so the icon host is transformable — an inline element
|
|
617
|
+
ignores the transform, which is why the spinner appeared frozen. */
|
|
618
|
+
display: inline-block;
|
|
619
|
+
animation: ai-stop-spin 0.8s linear infinite;
|
|
620
|
+
}
|
|
621
|
+
|
|
596
622
|
.input-left-controls {
|
|
597
623
|
display: flex;
|
|
598
624
|
flex-direction: column;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* lines let the formatter inject whitespace between them.
|
|
16
16
|
*/
|
|
17
17
|
import { isChatToolCallUnknown } from '@genesislcap/foundation-ai';
|
|
18
|
-
import { html, ref, repeat, when } from '@genesislcap/web-core';
|
|
18
|
+
import { classNames, html, ref, repeat, when } from '@genesislcap/web-core';
|
|
19
19
|
import { ANIMATION_DEFS } from './main.types';
|
|
20
20
|
function unknownToolPayload(tc) {
|
|
21
21
|
if (!isChatToolCallUnknown(tc))
|
|
@@ -206,7 +206,7 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
|
|
|
206
206
|
part="agent-toggle-button"
|
|
207
207
|
appearance="stealth"
|
|
208
208
|
title=${(x) => x.agentToggleTitle}
|
|
209
|
-
?disabled=${(x) => x.
|
|
209
|
+
?disabled=${(x) => x.busy || x.pinLocked}
|
|
210
210
|
@click=${(x) => x.toggleAgentPicker()}
|
|
211
211
|
>
|
|
212
212
|
${when((x) => x.agentPickerOpen, html `<${iconTag} name="chevron-down"></${iconTag}>`)}
|
|
@@ -227,7 +227,7 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
|
|
|
227
227
|
part="attach-button"
|
|
228
228
|
appearance="stealth"
|
|
229
229
|
title=${(x) => { var _a; return `Attach file (${(_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.acceptedFiles})`; }}
|
|
230
|
-
?disabled=${(x) => x.
|
|
230
|
+
?disabled=${(x) => x.busy}
|
|
231
231
|
@click=${(x) => x.triggerFileInput()}
|
|
232
232
|
><${iconTag} name="paperclip"></${iconTag}></${buttonTag}>
|
|
233
233
|
`;
|
|
@@ -496,7 +496,7 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
|
|
|
496
496
|
`)}
|
|
497
497
|
${when((x) => x.agentPickerEnabled && x.agentPickerOpen, agentPickerPanelTemplate)}
|
|
498
498
|
${when((x) => !x.composerHiddenByConfig &&
|
|
499
|
-
!(x.
|
|
499
|
+
!(x.busy && x.effectiveChatInputDuringExecution === 'hidden'), html `
|
|
500
500
|
<div class="input-row" part="input-row">
|
|
501
501
|
${when((x) => { var _a; return x.agentPickerEnabled || !!((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.acceptedFiles); }, inputLeftControlsTemplate)}
|
|
502
502
|
<${textareaTag}
|
|
@@ -505,7 +505,7 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
|
|
|
505
505
|
part="input"
|
|
506
506
|
placeholder=${(x) => x.effectivePlaceholder}
|
|
507
507
|
:value=${(x) => x.inputValue}
|
|
508
|
-
?disabled=${(x) => x.
|
|
508
|
+
?disabled=${(x) => x.busy}
|
|
509
509
|
@input=${(x, c) => (x.inputValue = c.event.target.value)}
|
|
510
510
|
@keydown=${(x, c) => {
|
|
511
511
|
if (c.event.key === 'Enter' &&
|
|
@@ -517,12 +517,28 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
|
|
|
517
517
|
return true;
|
|
518
518
|
}}
|
|
519
519
|
></${textareaTag}>
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
520
|
+
<div class="input-right-controls">
|
|
521
|
+
${when((x) => { var _a; return x.busy && !((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.hideStopButton); }, html `
|
|
522
|
+
<${buttonTag}
|
|
523
|
+
class="stop-button"
|
|
524
|
+
part="stop-button"
|
|
525
|
+
appearance="stealth"
|
|
526
|
+
title="${(x) => (x.state === 'cancelling' ? 'Stopping…' : 'Stop')}"
|
|
527
|
+
aria-label="${(x) => (x.state === 'cancelling' ? 'Stopping' : 'Stop')}"
|
|
528
|
+
?disabled=${(x) => x.state === 'cancelling'}
|
|
529
|
+
@click=${(x) => x.handleStopClick()}
|
|
530
|
+
><${iconTag}
|
|
531
|
+
class="${(x) => classNames('stop-icon', ['stop-icon-spinning', x.state === 'cancelling'])}"
|
|
532
|
+
name="${(x) => (x.state === 'cancelling' ? 'spinner' : 'stop')}"
|
|
533
|
+
></${iconTag}></${buttonTag}>
|
|
534
|
+
`)}
|
|
535
|
+
<${buttonTag}
|
|
536
|
+
class="send-button"
|
|
537
|
+
part="send-button"
|
|
538
|
+
?disabled=${(x) => x.busy || (!x.inputValue.trim() && !x.attachments.length)}
|
|
539
|
+
@click=${(x) => x.handleSendClick()}
|
|
540
|
+
>Send</${buttonTag}>
|
|
541
|
+
</div>
|
|
526
542
|
</div>
|
|
527
543
|
`)}
|
|
528
544
|
<ai-halo-overlay
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genesislcap/ai-assistant",
|
|
3
3
|
"description": "Genesis AI Assistant micro-frontend",
|
|
4
|
-
"version": "14.
|
|
4
|
+
"version": "14.454.0",
|
|
5
5
|
"license": "SEE LICENSE IN license.txt",
|
|
6
6
|
"main": "dist/esm/index.js",
|
|
7
7
|
"types": "dist/ai-assistant.d.ts",
|
|
@@ -64,24 +64,24 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@genesislcap/foundation-testing": "14.
|
|
68
|
-
"@genesislcap/genx": "14.
|
|
69
|
-
"@genesislcap/rollup-builder": "14.
|
|
70
|
-
"@genesislcap/ts-builder": "14.
|
|
71
|
-
"@genesislcap/uvu-playwright-builder": "14.
|
|
72
|
-
"@genesislcap/vite-builder": "14.
|
|
73
|
-
"@genesislcap/webpack-builder": "14.
|
|
67
|
+
"@genesislcap/foundation-testing": "14.454.0",
|
|
68
|
+
"@genesislcap/genx": "14.454.0",
|
|
69
|
+
"@genesislcap/rollup-builder": "14.454.0",
|
|
70
|
+
"@genesislcap/ts-builder": "14.454.0",
|
|
71
|
+
"@genesislcap/uvu-playwright-builder": "14.454.0",
|
|
72
|
+
"@genesislcap/vite-builder": "14.454.0",
|
|
73
|
+
"@genesislcap/webpack-builder": "14.454.0",
|
|
74
74
|
"@types/dompurify": "^3.0.5",
|
|
75
75
|
"@types/marked": "^5.0.2"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@genesislcap/foundation-ai": "14.
|
|
79
|
-
"@genesislcap/foundation-logger": "14.
|
|
80
|
-
"@genesislcap/foundation-redux": "14.
|
|
81
|
-
"@genesislcap/foundation-ui": "14.
|
|
82
|
-
"@genesislcap/foundation-utils": "14.
|
|
83
|
-
"@genesislcap/rapid-design-system": "14.
|
|
84
|
-
"@genesislcap/web-core": "14.
|
|
78
|
+
"@genesislcap/foundation-ai": "14.454.0",
|
|
79
|
+
"@genesislcap/foundation-logger": "14.454.0",
|
|
80
|
+
"@genesislcap/foundation-redux": "14.454.0",
|
|
81
|
+
"@genesislcap/foundation-ui": "14.454.0",
|
|
82
|
+
"@genesislcap/foundation-utils": "14.454.0",
|
|
83
|
+
"@genesislcap/rapid-design-system": "14.454.0",
|
|
84
|
+
"@genesislcap/web-core": "14.454.0",
|
|
85
85
|
"dompurify": "^3.3.1",
|
|
86
86
|
"marked": "^17.0.3"
|
|
87
87
|
},
|
|
@@ -93,5 +93,5 @@
|
|
|
93
93
|
"publishConfig": {
|
|
94
94
|
"access": "public"
|
|
95
95
|
},
|
|
96
|
-
"gitHead": "
|
|
96
|
+
"gitHead": "0e3b06e62bfcd7eb5cbd310842952736b002e002"
|
|
97
97
|
}
|
|
@@ -83,4 +83,19 @@ export interface AiDriver extends EventTarget {
|
|
|
83
83
|
* @beta
|
|
84
84
|
*/
|
|
85
85
|
getActiveProviderName(): string;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Tear down the driver, aborting any in-flight provider request. Called by
|
|
89
|
+
* the host when it swaps in a replacement driver. Optional because not every
|
|
90
|
+
* implementation needs teardown; the host invokes it when present.
|
|
91
|
+
*/
|
|
92
|
+
dispose?(): void | Promise<void>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Stop the current turn in response to a user "stop" action. Aborts the
|
|
96
|
+
* in-flight provider request; a tool already executing runs to completion and
|
|
97
|
+
* the loop stops at the next boundary. The driver stays usable afterwards.
|
|
98
|
+
* Optional; the host wires the stop button to it when present.
|
|
99
|
+
*/
|
|
100
|
+
cancel?(): void;
|
|
86
101
|
}
|
|
@@ -319,6 +319,233 @@ stale('splits stale vs hallucinated tools on the unknown-tool-limit error', asyn
|
|
|
319
319
|
|
|
320
320
|
stale.run();
|
|
321
321
|
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// timeout handling — a transport `TimeoutError` surfaces a distinct message
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
const timeout = createLogicSuite('ChatDriver timeout handling');
|
|
327
|
+
|
|
328
|
+
/** A provider whose `chat` rejects with the `TimeoutError` the transports tag
|
|
329
|
+
* a request timeout with (see gemini/anthropic `post`). */
|
|
330
|
+
const timesOutProvider = (): AIProvider => ({
|
|
331
|
+
chat: async (): Promise<ChatMessage> => {
|
|
332
|
+
throw new DOMException('Gemini request timed out', 'TimeoutError');
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
timeout('surfaces a timeout-specific message instead of the generic failure', async () => {
|
|
337
|
+
clearMetaEventRegistry();
|
|
338
|
+
const config = agent({
|
|
339
|
+
name: 'Static',
|
|
340
|
+
toolDefinitions: [def('noop')],
|
|
341
|
+
toolHandlers: { noop: async () => 'ok' },
|
|
342
|
+
});
|
|
343
|
+
const sessionKey = 'timeout-test';
|
|
344
|
+
const driver = makeDriver(config, timesOutProvider(), sessionKey);
|
|
345
|
+
|
|
346
|
+
const result = await driver.sendMessage('go');
|
|
347
|
+
assert.is(result.reason, 'done');
|
|
348
|
+
|
|
349
|
+
// The turn ends with the timeout message, NOT the generic "something went wrong".
|
|
350
|
+
const last = driver.getHistory().at(-1);
|
|
351
|
+
assert.ok(last?.role === 'assistant', 'turn ends with an assistant message');
|
|
352
|
+
assert.ok(last!.content.includes('timed out'), 'message names the timeout');
|
|
353
|
+
assert.not.ok(
|
|
354
|
+
last!.content.includes('something went wrong'),
|
|
355
|
+
'must not fall through to the generic catch',
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// Recorded distinctly in the debug log: a turn.error tagged TimeoutError,
|
|
359
|
+
// not swallowed silently.
|
|
360
|
+
const err = getMetaEvents(sessionKey).find((e) => e.type === 'turn.error');
|
|
361
|
+
assert.ok(err, 'a turn.error should be recorded');
|
|
362
|
+
assert.is(err!.detail?.reason, 'exception');
|
|
363
|
+
assert.is(err!.detail?.name, 'TimeoutError');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
timeout.run();
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// cancellation — dispose() aborts the in-flight provider call (lifecycle signal)
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
const cancel = createLogicSuite('ChatDriver cancellation');
|
|
373
|
+
|
|
374
|
+
cancel('threads a non-aborted signal into the provider call by default', async () => {
|
|
375
|
+
const config = agent({
|
|
376
|
+
name: 'Static',
|
|
377
|
+
toolDefinitions: [def('noop')],
|
|
378
|
+
toolHandlers: { noop: async () => 'ok' },
|
|
379
|
+
});
|
|
380
|
+
const capture: { signal?: AbortSignal } = {};
|
|
381
|
+
const provider: AIProvider = {
|
|
382
|
+
chat: async (_h, _m, options): Promise<ChatMessage> => {
|
|
383
|
+
capture.signal = options?.signal;
|
|
384
|
+
return { role: 'assistant', content: 'hi' };
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
const driver = makeDriver(config, provider, 'signal-plumb');
|
|
388
|
+
|
|
389
|
+
await driver.sendMessage('go');
|
|
390
|
+
|
|
391
|
+
assert.ok(capture.signal, 'provider received an options.signal');
|
|
392
|
+
assert.not.ok(capture.signal!.aborted, 'and it is not aborted on a normal turn');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
cancel('dispose() aborts the in-flight request and stops quietly', async () => {
|
|
396
|
+
clearMetaEventRegistry();
|
|
397
|
+
const config = agent({
|
|
398
|
+
name: 'Static',
|
|
399
|
+
toolDefinitions: [def('noop')],
|
|
400
|
+
toolHandlers: { noop: async () => 'ok' },
|
|
401
|
+
});
|
|
402
|
+
// A provider that hangs until its signal aborts, then rejects with the
|
|
403
|
+
// signal's reason — mirroring how the transport surfaces an aborted fetch.
|
|
404
|
+
const capture: { signal?: AbortSignal } = {};
|
|
405
|
+
const provider: AIProvider = {
|
|
406
|
+
chat: (_h, _m, options): Promise<ChatMessage> => {
|
|
407
|
+
const signal = options?.signal;
|
|
408
|
+
capture.signal = signal;
|
|
409
|
+
return new Promise<ChatMessage>((_resolve, reject) => {
|
|
410
|
+
if (!signal) return;
|
|
411
|
+
if (signal.aborted) {
|
|
412
|
+
reject(signal.reason);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
const driver = makeDriver(config, provider, 'cancel-test');
|
|
420
|
+
|
|
421
|
+
const pending = driver.sendMessage('go');
|
|
422
|
+
driver.dispose();
|
|
423
|
+
const result = await pending;
|
|
424
|
+
|
|
425
|
+
assert.is(result.reason, 'done');
|
|
426
|
+
assert.ok(capture.signal?.aborted, 'the provider signal aborted on dispose');
|
|
427
|
+
assert.is(capture.signal!.reason.name, 'AbortError');
|
|
428
|
+
|
|
429
|
+
// Quiet: no assistant message appended (would otherwise pollute cached history).
|
|
430
|
+
const assistantMsgs = driver.getHistory().filter((m) => m.role === 'assistant');
|
|
431
|
+
assert.is(assistantMsgs.length, 0, 'no message appended on a disposal abort');
|
|
432
|
+
assert.not.ok(
|
|
433
|
+
driver.getHistory().some((m) => m.content?.includes('something went wrong')),
|
|
434
|
+
'must not surface the generic failure',
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
cancel(
|
|
439
|
+
'cancel() stops the turn with a "Stopped." marker and leaves the driver usable',
|
|
440
|
+
async () => {
|
|
441
|
+
clearMetaEventRegistry();
|
|
442
|
+
const config = agent({
|
|
443
|
+
name: 'Static',
|
|
444
|
+
toolDefinitions: [def('noop')],
|
|
445
|
+
toolHandlers: { noop: async () => 'ok' },
|
|
446
|
+
});
|
|
447
|
+
let call = 0;
|
|
448
|
+
const provider: AIProvider = {
|
|
449
|
+
chat: (_h, _m, options): Promise<ChatMessage> => {
|
|
450
|
+
call += 1;
|
|
451
|
+
if (call === 1) {
|
|
452
|
+
// First turn hangs until its signal aborts (the user cancel). Must also
|
|
453
|
+
// handle the already-aborted case: cancel can land before chat runs
|
|
454
|
+
// (during the provider-resolution await), so the signal may arrive
|
|
455
|
+
// aborted — mirror how real fetch rejects immediately in that case.
|
|
456
|
+
const signal = options!.signal!;
|
|
457
|
+
return new Promise<ChatMessage>((_resolve, reject) => {
|
|
458
|
+
if (signal.aborted) {
|
|
459
|
+
reject(signal.reason);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
// Second turn resolves normally — proves the driver wasn't bricked.
|
|
466
|
+
return Promise.resolve({ role: 'assistant', content: 'second response' });
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
const driver = makeDriver(config, provider, 'cancel-stop');
|
|
470
|
+
|
|
471
|
+
const pending = driver.sendMessage('go');
|
|
472
|
+
driver.cancel();
|
|
473
|
+
const result = await pending;
|
|
474
|
+
|
|
475
|
+
assert.is(result.reason, 'done');
|
|
476
|
+
// Subtle 'Stopped.' marker (system-event, not a generic failure).
|
|
477
|
+
const last = driver.getHistory().at(-1);
|
|
478
|
+
assert.is(last?.role, 'system-event');
|
|
479
|
+
assert.is(last?.content, 'Stopped.');
|
|
480
|
+
|
|
481
|
+
// The driver is still usable for the next message.
|
|
482
|
+
const second = await driver.sendMessage('again');
|
|
483
|
+
assert.is(second.reason, 'done');
|
|
484
|
+
assert.ok(
|
|
485
|
+
driver.getHistory().some((m) => m.content === 'second response'),
|
|
486
|
+
'a fresh turn runs after a cancel',
|
|
487
|
+
);
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
cancel('a tool already running finishes, then the loop stops at the boundary', async () => {
|
|
492
|
+
let toolFinished = false;
|
|
493
|
+
let secondChatCalled = false;
|
|
494
|
+
const config = agent({
|
|
495
|
+
name: 'Static',
|
|
496
|
+
toolDefinitions: [def('slow')],
|
|
497
|
+
toolHandlers: {
|
|
498
|
+
slow: async () => {
|
|
499
|
+
// Cancel mid-tool: the tool must still run to completion.
|
|
500
|
+
driver.cancel();
|
|
501
|
+
await Promise.resolve();
|
|
502
|
+
toolFinished = true;
|
|
503
|
+
return 'tool done';
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
let call = 0;
|
|
508
|
+
const provider: AIProvider = {
|
|
509
|
+
chat: async (): Promise<ChatMessage> => {
|
|
510
|
+
call += 1;
|
|
511
|
+
if (call === 1) {
|
|
512
|
+
return {
|
|
513
|
+
role: 'assistant',
|
|
514
|
+
content: '',
|
|
515
|
+
toolCalls: [{ id: 't1', name: 'slow', args: {} }],
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
secondChatCalled = true;
|
|
519
|
+
return { role: 'assistant', content: 'should not be reached' };
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
const driver = makeDriver(config, provider, 'cancel-tool');
|
|
523
|
+
|
|
524
|
+
const result = await driver.sendMessage('go');
|
|
525
|
+
|
|
526
|
+
assert.is(result.reason, 'done');
|
|
527
|
+
assert.ok(toolFinished, 'the in-flight tool ran to completion (tools are atomic)');
|
|
528
|
+
assert.not.ok(secondChatCalled, 'no further LLM call is made after cancel');
|
|
529
|
+
const last = driver.getHistory().at(-1);
|
|
530
|
+
assert.is(last?.role, 'system-event');
|
|
531
|
+
assert.is(last?.content, 'Stopped.');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
cancel('cancel() is a no-op when no turn is running', () => {
|
|
535
|
+
const config = agent({
|
|
536
|
+
name: 'Static',
|
|
537
|
+
toolDefinitions: [def('noop')],
|
|
538
|
+
toolHandlers: { noop: async () => 'ok' },
|
|
539
|
+
});
|
|
540
|
+
const driver = makeDriver(config, scriptedProvider([]), 'cancel-idle');
|
|
541
|
+
|
|
542
|
+
driver.cancel(); // must not throw or append anything while idle
|
|
543
|
+
|
|
544
|
+
assert.is(driver.getHistory().length, 0);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
cancel.run();
|
|
548
|
+
|
|
322
549
|
// ---------------------------------------------------------------------------
|
|
323
550
|
// sub-agents — forced tool use + typed completion/failure union (GENC-1312)
|
|
324
551
|
//
|
|
@@ -273,6 +273,28 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
273
273
|
private debugSnapshotter?: () => unknown;
|
|
274
274
|
private readonly maxTurnSnapshots: number;
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Aborted by `dispose()` on driver teardown (e.g. an agent-config swap).
|
|
278
|
+
* Threaded into every provider call as `ChatRequestOptions.signal`, so a
|
|
279
|
+
* disposed driver's in-flight LLM request is cancelled instead of running on
|
|
280
|
+
* to completion or the transport timeout. Also passed to prompt/tool
|
|
281
|
+
* factories via `SystemPromptContext.signal`.
|
|
282
|
+
*/
|
|
283
|
+
private readonly lifecycleController = new AbortController();
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Per-turn abort controller, reset at the start of every turn by `beginTurn`.
|
|
287
|
+
* Aborted by `cancel()` (user stop) and chained to `lifecycleController` so a
|
|
288
|
+
* driver dispose also ends the current turn. Its signal — not the lifecycle
|
|
289
|
+
* one — is what reaches the provider call, so a turn can be cancelled without
|
|
290
|
+
* bricking the driver for the next message.
|
|
291
|
+
*/
|
|
292
|
+
private turnController = new AbortController();
|
|
293
|
+
/** True when the current turn was stopped via `cancel()` (vs a dispose). Drives the "Stopped." marker. */
|
|
294
|
+
private turnCancelled = false;
|
|
295
|
+
/** Detaches the lifecycle→turn abort link at turn end; set by `beginTurn`. */
|
|
296
|
+
private unlinkLifecycleFromTurn?: () => void;
|
|
297
|
+
|
|
276
298
|
/**
|
|
277
299
|
* Active agent's provider selector (static name or per-turn resolver).
|
|
278
300
|
* `undefined` means "use the registry default".
|
|
@@ -322,6 +344,67 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
322
344
|
this.maxTurnSnapshots = maxTurnSnapshots;
|
|
323
345
|
}
|
|
324
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Tear down the driver: aborts the lifecycle signal so any in-flight provider
|
|
349
|
+
* request (and prompt/tool factories awaiting it) cancels instead of running
|
|
350
|
+
* on to completion or the transport timeout. Called by the host on driver
|
|
351
|
+
* swap and by `OrchestratingDriver.dispose()`. Idempotent.
|
|
352
|
+
*/
|
|
353
|
+
dispose(): void {
|
|
354
|
+
this.lifecycleController.abort(new DOMException('AI assistant driver disposed', 'AbortError'));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Stop the current turn (user "stop" button). Aborts the in-flight provider
|
|
359
|
+
* request immediately; if a tool is mid-execution it runs to completion and
|
|
360
|
+
* the loop bails at the next boundary (tools are atomic). No-op when idle.
|
|
361
|
+
* The driver stays usable for the next message.
|
|
362
|
+
*/
|
|
363
|
+
cancel(): void {
|
|
364
|
+
if (!this.busy) return;
|
|
365
|
+
this.turnCancelled = true;
|
|
366
|
+
this.turnController.abort(new DOMException('Cancelled by user', 'AbortError'));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Start a fresh per-turn abort scope. Chains `lifecycleController` into the
|
|
371
|
+
* new `turnController` so a dispose mid-turn also aborts the request.
|
|
372
|
+
*/
|
|
373
|
+
private beginTurn(): void {
|
|
374
|
+
this.turnCancelled = false;
|
|
375
|
+
this.turnController = new AbortController();
|
|
376
|
+
const lifecycle = this.lifecycleController.signal;
|
|
377
|
+
if (lifecycle.aborted) {
|
|
378
|
+
this.turnController.abort(lifecycle.reason);
|
|
379
|
+
this.unlinkLifecycleFromTurn = undefined;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const onDispose = () => this.turnController.abort(lifecycle.reason);
|
|
383
|
+
lifecycle.addEventListener('abort', onDispose, { once: true });
|
|
384
|
+
this.unlinkLifecycleFromTurn = () => lifecycle.removeEventListener('abort', onDispose);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Detach the lifecycle→turn link so a long-lived lifecycle signal doesn't accumulate listeners. */
|
|
388
|
+
private endTurn(): void {
|
|
389
|
+
this.unlinkLifecycleFromTurn?.();
|
|
390
|
+
this.unlinkLifecycleFromTurn = undefined;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Finish a turn whose signal aborted. A user cancel adds a subtle "Stopped."
|
|
395
|
+
* marker; a dispose-driven abort stops quietly (the widget is gone and the
|
|
396
|
+
* cached history would otherwise gain a stray marker on remount).
|
|
397
|
+
*/
|
|
398
|
+
private completeAbortedTurn(): ChatDriverResult {
|
|
399
|
+
if (this.turnCancelled) {
|
|
400
|
+
logger.warn('ChatDriver: turn cancelled by user');
|
|
401
|
+
this.appendToHistory({ role: 'system-event', content: 'Stopped.' });
|
|
402
|
+
} else {
|
|
403
|
+
logger.warn('ChatDriver: turn aborted (driver disposed)');
|
|
404
|
+
}
|
|
405
|
+
return { reason: 'done' };
|
|
406
|
+
}
|
|
407
|
+
|
|
325
408
|
/**
|
|
326
409
|
* Swap in a new agent's configuration. Called by OrchestratingDriver before
|
|
327
410
|
* each specialist turn so the shared driver runs with the right tools and prompt.
|
|
@@ -822,6 +905,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
822
905
|
if (this.busy || (!userInput.trim() && !attachments?.length)) return { reason: 'done' };
|
|
823
906
|
|
|
824
907
|
this.busy = true;
|
|
908
|
+
this.beginTurn();
|
|
825
909
|
this.subAgentCompletion = undefined;
|
|
826
910
|
this.subAgentFailure = undefined;
|
|
827
911
|
this.agentReleaseRequested = false;
|
|
@@ -856,6 +940,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
856
940
|
durationMs: Date.now() - this.turnStartedAt,
|
|
857
941
|
});
|
|
858
942
|
this.busy = false;
|
|
943
|
+
this.endTurn();
|
|
859
944
|
agenticActivityBus.publish('tool-loop-end', undefined);
|
|
860
945
|
}
|
|
861
946
|
}
|
|
@@ -963,6 +1048,15 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
963
1048
|
// Mark before the first turn so the child forces tool use and reports a
|
|
964
1049
|
// typed failure (rather than user-facing text) if it never completes.
|
|
965
1050
|
child.markAsSubAgent();
|
|
1051
|
+
// Propagate disposal: if this (parent) driver is torn down while the
|
|
1052
|
+
// sub-agent is mid-flight, dispose the child too so its in-flight request
|
|
1053
|
+
// aborts. Detached in the `finally` below once the sub-agent completes.
|
|
1054
|
+
const disposeChild = () => child.dispose();
|
|
1055
|
+
if (this.lifecycleController.signal.aborted) {
|
|
1056
|
+
disposeChild();
|
|
1057
|
+
} else {
|
|
1058
|
+
this.lifecycleController.signal.addEventListener('abort', disposeChild, { once: true });
|
|
1059
|
+
}
|
|
966
1060
|
child.applyAgent({ ...subConfig, primerHistory: effectivePrimer });
|
|
967
1061
|
// Route interactions back through this driver so widgets render in the
|
|
968
1062
|
// parent's (ultimately the root's) history and resolve via the same
|
|
@@ -1002,6 +1096,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1002
1096
|
try {
|
|
1003
1097
|
await child.sendMessage(task ?? '');
|
|
1004
1098
|
} finally {
|
|
1099
|
+
this.lifecycleController.signal.removeEventListener('abort', disposeChild);
|
|
1005
1100
|
child.removeEventListener('history-updated', forwardTrace);
|
|
1006
1101
|
child.removeEventListener('provider-changed', forwardProviderChanged);
|
|
1007
1102
|
this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: lifecycleDetail }));
|
|
@@ -1042,6 +1137,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1042
1137
|
if (this.busy) return { reason: 'done' };
|
|
1043
1138
|
|
|
1044
1139
|
this.busy = true;
|
|
1140
|
+
this.beginTurn();
|
|
1045
1141
|
this.subAgentCompletion = undefined;
|
|
1046
1142
|
this.subAgentFailure = undefined;
|
|
1047
1143
|
this.turnStartedAt = Date.now();
|
|
@@ -1073,6 +1169,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1073
1169
|
durationMs: Date.now() - this.turnStartedAt,
|
|
1074
1170
|
});
|
|
1075
1171
|
this.busy = false;
|
|
1172
|
+
this.endTurn();
|
|
1076
1173
|
agenticActivityBus.publish('tool-loop-end', undefined);
|
|
1077
1174
|
}
|
|
1078
1175
|
}
|
|
@@ -1278,11 +1375,18 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1278
1375
|
while (iterations < this.maxToolIterations) {
|
|
1279
1376
|
iterations += 1;
|
|
1280
1377
|
|
|
1378
|
+
// A cancel (or dispose) that landed while the previous iteration's tool
|
|
1379
|
+
// batch was running takes effect here — before issuing another LLM call.
|
|
1380
|
+
// An abort during the call itself is handled by the catch below.
|
|
1381
|
+
if (this.turnController.signal.aborted) {
|
|
1382
|
+
return this.completeAbortedTurn();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1281
1385
|
const promptCtx: SystemPromptContext = {
|
|
1282
1386
|
agentName: this.activeAgentName ?? '',
|
|
1283
1387
|
history: this.history,
|
|
1284
1388
|
turnIndex: iterations - 1,
|
|
1285
|
-
signal:
|
|
1389
|
+
signal: this.turnController.signal,
|
|
1286
1390
|
};
|
|
1287
1391
|
|
|
1288
1392
|
// Re-resolve dynamic tool definitions before each LLM call. The static
|
|
@@ -1377,6 +1481,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1377
1481
|
// Strip fold-only properties (foldEvent, foldPath) before sending to provider
|
|
1378
1482
|
tools: this.toolDefinitions.length ? this.toolDefinitions : undefined,
|
|
1379
1483
|
attachments: attachmentsForCall,
|
|
1484
|
+
// Per-turn signal: aborts on user cancel, and (via beginTurn's chain)
|
|
1485
|
+
// on driver dispose. Cancels the in-flight request either way.
|
|
1486
|
+
signal: this.turnController.signal,
|
|
1380
1487
|
// Sub-agents must finish by calling a tool (their completion tool), never
|
|
1381
1488
|
// by emitting a free-text turn — force tool use so the provider can't
|
|
1382
1489
|
// return a bare text answer. Top-level agents stay on the default 'auto'.
|
|
@@ -1431,6 +1538,34 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1431
1538
|
}
|
|
1432
1539
|
return { reason: 'done' };
|
|
1433
1540
|
}
|
|
1541
|
+
// A request timeout from the transport (tagged `TimeoutError`) is not a
|
|
1542
|
+
// bug on our end — surface it distinctly instead of letting it fall
|
|
1543
|
+
// through to the generic "something went wrong" catch. No auto-retry:
|
|
1544
|
+
// the timeout ceiling is already minutes long, so a silent retry would
|
|
1545
|
+
// just make the user wait again.
|
|
1546
|
+
if (e instanceof DOMException && e.name === 'TimeoutError') {
|
|
1547
|
+
logger.error('ChatDriver: request timed out', e);
|
|
1548
|
+
recordTurnError(this.sessionKey, 'exception', {
|
|
1549
|
+
agent: this.activeAgentName,
|
|
1550
|
+
provider: this.lastResolvedProviderName,
|
|
1551
|
+
name: e.name,
|
|
1552
|
+
message: e.message,
|
|
1553
|
+
});
|
|
1554
|
+
this.appendToHistory({
|
|
1555
|
+
role: 'assistant',
|
|
1556
|
+
content:
|
|
1557
|
+
'The request timed out. You can ask me to try again, or break this into a smaller step.',
|
|
1558
|
+
});
|
|
1559
|
+
return { reason: 'done' };
|
|
1560
|
+
}
|
|
1561
|
+
// The request was aborted: either a user cancel (turnController) or a
|
|
1562
|
+
// driver dispose (lifecycleController, chained into the turn). A user
|
|
1563
|
+
// cancel adds a "Stopped." marker; a dispose stops quietly. Handled
|
|
1564
|
+
// before the transient-retry below so an intentional abort/timeout is
|
|
1565
|
+
// never retried.
|
|
1566
|
+
if (e instanceof DOMException && e.name === 'AbortError') {
|
|
1567
|
+
return this.completeAbortedTurn();
|
|
1568
|
+
}
|
|
1434
1569
|
// A transient provider/transport error should retry the SAME iteration a bounded
|
|
1435
1570
|
// number of times rather than tearing down the whole turn (which strands the
|
|
1436
1571
|
// agent's unflushed buffer behind an opaque error message).
|