@genesislcap/ai-assistant 14.461.2 → 14.462.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 +55 -1
- package/dist/ai-assistant.d.ts +52 -0
- package/dist/dts/channel/ai-activity-channel.d.ts +20 -0
- package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +28 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +4 -0
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +87 -1
- package/dist/esm/components/chat-driver/chat-driver.test.js +216 -0
- package/dist/esm/main/main.js +23 -6
- package/dist/esm/state/debug-event-log.js +1 -1
- package/package.json +16 -16
- package/src/channel/ai-activity-channel.ts +20 -0
- package/src/components/chat-driver/chat-driver.test.ts +290 -0
- package/src/components/chat-driver/chat-driver.ts +102 -1
- package/src/main/main.ts +20 -1
- package/src/state/debug-event-log.ts +1 -1
|
@@ -1094,6 +1094,56 @@ interactionPresentation('presentation is absent when the option is omitted', asy
|
|
|
1094
1094
|
|
|
1095
1095
|
interactionPresentation.run();
|
|
1096
1096
|
|
|
1097
|
+
// ---------------------------------------------------------------------------
|
|
1098
|
+
// interaction activity-bus signals (GENC-1346) — the driver brackets a parked
|
|
1099
|
+
// widget interaction with `interaction-requested` / `interaction-resolved`, so
|
|
1100
|
+
// turn-aware UI can distinguish "actively computing" from "parked awaiting the
|
|
1101
|
+
// user". No tool-loop event fires at a park boundary, so these are the signal.
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
|
|
1104
|
+
const interactionBus = createLogicSuite('ChatDriver interaction activity-bus signals');
|
|
1105
|
+
|
|
1106
|
+
interactionBus.after(() => {
|
|
1107
|
+
agenticActivityBus.close();
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
interactionBus('brackets a park with interaction-requested then -resolved', async () => {
|
|
1111
|
+
const events: string[] = [];
|
|
1112
|
+
const unsubs = [
|
|
1113
|
+
agenticActivityBus.subscribe('interaction-requested', () => events.push('requested')),
|
|
1114
|
+
agenticActivityBus.subscribe('interaction-resolved', () => events.push('resolved')),
|
|
1115
|
+
];
|
|
1116
|
+
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1117
|
+
|
|
1118
|
+
const pending = driver.requestInteraction('w', {});
|
|
1119
|
+
assert.equal(events, ['requested'], 'parking fires interaction-requested');
|
|
1120
|
+
|
|
1121
|
+
const id = driver.getHistory().at(-1)!.interaction!.interactionId;
|
|
1122
|
+
driver.resolveInteraction(id, { status: 'approved' });
|
|
1123
|
+
await pending;
|
|
1124
|
+
assert.equal(events, ['requested', 'resolved'], 'resolving fires interaction-resolved');
|
|
1125
|
+
|
|
1126
|
+
unsubs.forEach((u) => u());
|
|
1127
|
+
driver.dispose();
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
interactionBus('a timed-out interaction still fires interaction-resolved', async () => {
|
|
1131
|
+
const events: string[] = [];
|
|
1132
|
+
const unsub = agenticActivityBus.subscribe('interaction-resolved', () => events.push('resolved'));
|
|
1133
|
+
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
|
|
1134
|
+
|
|
1135
|
+
// Never resolved by a user — the timeout path runs the same teardown, so it
|
|
1136
|
+
// must signal the bus too (else the button would stay enabled after a timeout).
|
|
1137
|
+
const pending = driver.requestInteraction('w', {}, { timeoutMs: 1 });
|
|
1138
|
+
await pending;
|
|
1139
|
+
assert.equal(events, ['resolved'], 'the timeout resolution path also signals the bus');
|
|
1140
|
+
|
|
1141
|
+
unsub();
|
|
1142
|
+
driver.dispose();
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
interactionBus.run();
|
|
1146
|
+
|
|
1097
1147
|
// ---------------------------------------------------------------------------
|
|
1098
1148
|
// interaction timeout — requestInteraction({ timeoutMs }) resolves with a
|
|
1099
1149
|
// status:'timeout' result (never rejects) and closes the widget read-only.
|
|
@@ -1187,3 +1237,243 @@ interactionCost(
|
|
|
1187
1237
|
);
|
|
1188
1238
|
|
|
1189
1239
|
interactionCost.run();
|
|
1240
|
+
|
|
1241
|
+
// ---------------------------------------------------------------------------
|
|
1242
|
+
// observable provider registry — runtime provider switching (GENC-1346)
|
|
1243
|
+
//
|
|
1244
|
+
// When the host registers an observable registry and swaps providers at
|
|
1245
|
+
// runtime, the driver must drop its memoised lookups so the next turn resolves
|
|
1246
|
+
// the new provider, re-emit `provider-changed` even when the resolved *name* is
|
|
1247
|
+
// unchanged, and (critically) never leak its subscription — including for child
|
|
1248
|
+
// sub-agent drivers that complete normally.
|
|
1249
|
+
// ---------------------------------------------------------------------------
|
|
1250
|
+
|
|
1251
|
+
interface ObservableTestRegistry extends AIProviderRegistry {
|
|
1252
|
+
subscribe(listener: () => void): () => void;
|
|
1253
|
+
/** Swap the provider returned by `get`/`default` and notify subscribers. */
|
|
1254
|
+
swap(provider: AIProvider): void;
|
|
1255
|
+
/** Live subscriber count — lets tests assert there's no listener leak. */
|
|
1256
|
+
listenerCount(): number;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* A single-name (`'high'`) observable registry. `get`/`default` always return
|
|
1261
|
+
* the current provider, so swapping it mid-session models a same-name vendor
|
|
1262
|
+
* switch (the tier name stays `'high'`, the provider underneath changes).
|
|
1263
|
+
*/
|
|
1264
|
+
const makeObservableRegistry = (initial: AIProvider): ObservableTestRegistry => {
|
|
1265
|
+
let current = initial;
|
|
1266
|
+
const listeners = new Set<() => void>();
|
|
1267
|
+
return {
|
|
1268
|
+
get: () => current,
|
|
1269
|
+
default: () => current,
|
|
1270
|
+
defaultName: () => 'high',
|
|
1271
|
+
names: () => ['high'],
|
|
1272
|
+
getStatus: async () => null,
|
|
1273
|
+
listStatuses: async () => [],
|
|
1274
|
+
subscribe(listener: () => void) {
|
|
1275
|
+
listeners.add(listener);
|
|
1276
|
+
return () => {
|
|
1277
|
+
listeners.delete(listener);
|
|
1278
|
+
};
|
|
1279
|
+
},
|
|
1280
|
+
swap(provider: AIProvider) {
|
|
1281
|
+
current = provider;
|
|
1282
|
+
for (const l of Array.from(listeners)) l();
|
|
1283
|
+
},
|
|
1284
|
+
listenerCount: () => listeners.size,
|
|
1285
|
+
};
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
const makeDriverWithRegistry = (config: AgentConfig, registry: AIProviderRegistry): ChatDriver => {
|
|
1289
|
+
const driver = new ChatDriver(registry, {}, [], undefined, undefined, 50, 5, undefined, '');
|
|
1290
|
+
driver.applyAgent(config);
|
|
1291
|
+
return driver;
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
const observable = createLogicSuite('ChatDriver observable provider registry');
|
|
1295
|
+
|
|
1296
|
+
observable.after(() => {
|
|
1297
|
+
agenticActivityBus.close();
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
observable(
|
|
1301
|
+
'a registry change clears the resolved-provider cache so the next turn uses the new provider',
|
|
1302
|
+
async () => {
|
|
1303
|
+
const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
|
|
1304
|
+
const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
|
|
1305
|
+
const registry = makeObservableRegistry(providerA);
|
|
1306
|
+
// A static provider name means lookups go through `resolvedProviderCache` —
|
|
1307
|
+
// the cache that must self-invalidate on a registry change.
|
|
1308
|
+
const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
|
|
1309
|
+
|
|
1310
|
+
await driver.sendMessage('first');
|
|
1311
|
+
assert.is(providerA.advertisedPerCall.length, 1, 'turn 1 resolves the original provider');
|
|
1312
|
+
assert.is(providerB.advertisedPerCall.length, 0);
|
|
1313
|
+
|
|
1314
|
+
registry.swap(providerB); // notify → cache cleared
|
|
1315
|
+
|
|
1316
|
+
await driver.sendMessage('second');
|
|
1317
|
+
assert.is(providerB.advertisedPerCall.length, 1, 'turn 2 resolves the swapped-in provider');
|
|
1318
|
+
assert.is(providerA.advertisedPerCall.length, 1, 'the stale provider is not reused');
|
|
1319
|
+
|
|
1320
|
+
driver.dispose();
|
|
1321
|
+
},
|
|
1322
|
+
);
|
|
1323
|
+
|
|
1324
|
+
observable('re-emits provider-changed on a same-name swap', async () => {
|
|
1325
|
+
const providerA = scriptedProvider([{ role: 'assistant', content: 'A' }]);
|
|
1326
|
+
const providerB = scriptedProvider([{ role: 'assistant', content: 'B' }]);
|
|
1327
|
+
const registry = makeObservableRegistry(providerA);
|
|
1328
|
+
const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
|
|
1329
|
+
|
|
1330
|
+
const names: string[] = [];
|
|
1331
|
+
driver.addEventListener('provider-changed', (e) => {
|
|
1332
|
+
names.push((e as CustomEvent<{ name: string }>).detail.name);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
await driver.sendMessage('first');
|
|
1336
|
+
registry.swap(providerB);
|
|
1337
|
+
await driver.sendMessage('second');
|
|
1338
|
+
|
|
1339
|
+
// The resolved name ('high') never changes, but the swap resets the
|
|
1340
|
+
// last-dispatched name so the cog can refresh — two events, not one.
|
|
1341
|
+
assert.equal(names, ['high', 'high']);
|
|
1342
|
+
|
|
1343
|
+
driver.dispose();
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
observable('a non-observable registry is a no-op — turn runs, dispose does not throw', async () => {
|
|
1347
|
+
const provider = scriptedProvider([{ role: 'assistant', content: 'hi' }]);
|
|
1348
|
+
const driver = makeDriverWithRegistry(agent({ name: 'plain' }), makeRegistry(provider));
|
|
1349
|
+
await driver.sendMessage('go');
|
|
1350
|
+
assert.is(provider.advertisedPerCall.length, 1);
|
|
1351
|
+
driver.dispose(); // no subscription was wired — must still be safe
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
observable('a child sub-agent driver unsubscribes on completion — no listener leak', async () => {
|
|
1355
|
+
const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
|
|
1356
|
+
const registry = makeObservableRegistry(provider);
|
|
1357
|
+
const parent = delegatingParent(completingWorker({ ok: true }), () => {});
|
|
1358
|
+
const driver = makeDriverWithRegistry(parent, registry);
|
|
1359
|
+
|
|
1360
|
+
assert.is(registry.listenerCount(), 1, 'the parent driver subscribed on construction');
|
|
1361
|
+
|
|
1362
|
+
await driver.sendMessage('go');
|
|
1363
|
+
// The child subscribed during the run; if it didn't clean up on its (normal)
|
|
1364
|
+
// completion the registry would now hold two listeners.
|
|
1365
|
+
assert.is(registry.listenerCount(), 1, 'the completed child unsubscribed');
|
|
1366
|
+
|
|
1367
|
+
driver.dispose();
|
|
1368
|
+
assert.is(registry.listenerCount(), 0, 'the parent unsubscribed on dispose');
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
observable.run();
|
|
1372
|
+
|
|
1373
|
+
// ---------------------------------------------------------------------------
|
|
1374
|
+
// per-message model attribution (GENC-1346)
|
|
1375
|
+
//
|
|
1376
|
+
// Each model-produced assistant message carries `model` (the concrete model id
|
|
1377
|
+
// the active provider's getStatus reports) and `providerName` (the registry slot
|
|
1378
|
+
// it resolved under), so the exported debug log shows which model produced each
|
|
1379
|
+
// message — and, since tool calls ride on the assistant message, each tool call.
|
|
1380
|
+
// ---------------------------------------------------------------------------
|
|
1381
|
+
|
|
1382
|
+
/** A provider that replays scripted replies and reports `model` via getStatus
|
|
1383
|
+
* (omitted entirely when `model` is undefined, to model a provider with no
|
|
1384
|
+
* status). */
|
|
1385
|
+
const modelProvider = (model: string | undefined, responses: ChatMessage[]): AIProvider => {
|
|
1386
|
+
const queue = [...responses];
|
|
1387
|
+
const provider: AIProvider = {
|
|
1388
|
+
chat: async (): Promise<ChatMessage> => queue.shift() ?? { role: 'assistant', content: 'done' },
|
|
1389
|
+
};
|
|
1390
|
+
if (model !== undefined) {
|
|
1391
|
+
provider.getStatus = async () => ({ provider: 'gemini', model });
|
|
1392
|
+
}
|
|
1393
|
+
return provider;
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
const modelAttr = createLogicSuite('ChatDriver per-message model attribution');
|
|
1397
|
+
|
|
1398
|
+
modelAttr.after(() => {
|
|
1399
|
+
agenticActivityBus.close();
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
modelAttr('stamps the resolved model and registry name onto an assistant reply', async () => {
|
|
1403
|
+
const provider = modelProvider('gemini-2.5-flash-lite', [
|
|
1404
|
+
{ role: 'assistant', content: 'hi there' },
|
|
1405
|
+
]);
|
|
1406
|
+
const driver = makeDriver(agent({ name: 'plain' }), provider);
|
|
1407
|
+
|
|
1408
|
+
await driver.sendMessage('hello');
|
|
1409
|
+
|
|
1410
|
+
const reply = driver.getHistory().find((m) => m.role === 'assistant');
|
|
1411
|
+
assert.ok(reply, 'assistant reply present');
|
|
1412
|
+
assert.is(reply!.model, 'gemini-2.5-flash-lite', 'model id read from provider getStatus');
|
|
1413
|
+
// makeRegistry registers a single provider under the name 'test'.
|
|
1414
|
+
assert.is(reply!.providerName, 'test', 'registry slot the turn resolved under');
|
|
1415
|
+
|
|
1416
|
+
driver.dispose();
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
modelAttr(
|
|
1420
|
+
'attributes a tool-calling assistant message (and so its tool calls) to the model',
|
|
1421
|
+
async () => {
|
|
1422
|
+
const provider = modelProvider('claude-haiku-4-5-20251001', [
|
|
1423
|
+
callsTool('noop', 't1'),
|
|
1424
|
+
{ role: 'assistant', content: 'finished' },
|
|
1425
|
+
]);
|
|
1426
|
+
const config = agent({
|
|
1427
|
+
name: 'withTool',
|
|
1428
|
+
toolDefinitions: [def('noop')],
|
|
1429
|
+
toolHandlers: { noop: async () => 'ok' },
|
|
1430
|
+
});
|
|
1431
|
+
const driver = makeDriver(config, provider);
|
|
1432
|
+
|
|
1433
|
+
await driver.sendMessage('go');
|
|
1434
|
+
|
|
1435
|
+
const toolCallMsg = driver.getHistory().find((m) => (m.toolCalls?.length ?? 0) > 0);
|
|
1436
|
+
assert.ok(toolCallMsg, 'an assistant message with tool calls is present');
|
|
1437
|
+
assert.is(toolCallMsg!.model, 'claude-haiku-4-5-20251001');
|
|
1438
|
+
assert.is(toolCallMsg!.providerName, 'test');
|
|
1439
|
+
|
|
1440
|
+
driver.dispose();
|
|
1441
|
+
},
|
|
1442
|
+
);
|
|
1443
|
+
|
|
1444
|
+
modelAttr('picks up a model swapped behind a stable name', async () => {
|
|
1445
|
+
const before = modelProvider('gemini-2.5-flash-lite', [{ role: 'assistant', content: 'A' }]);
|
|
1446
|
+
const after = modelProvider('gemini-2.5-pro', [{ role: 'assistant', content: 'B' }]);
|
|
1447
|
+
const registry = makeObservableRegistry(before);
|
|
1448
|
+
const driver = makeDriverWithRegistry(agent({ name: 'tiered', provider: 'high' }), registry);
|
|
1449
|
+
|
|
1450
|
+
await driver.sendMessage('first');
|
|
1451
|
+
registry.swap(after); // notify → model cache cleared, next turn re-resolves
|
|
1452
|
+
await driver.sendMessage('second');
|
|
1453
|
+
|
|
1454
|
+
const replies = driver.getHistory().filter((m) => m.role === 'assistant');
|
|
1455
|
+
assert.is(replies.at(-2)?.model, 'gemini-2.5-flash-lite', 'first turn keeps the original model');
|
|
1456
|
+
assert.is(replies.at(-1)?.model, 'gemini-2.5-pro', 'after the swap the new model is stamped');
|
|
1457
|
+
// The tier name ('high') never changed across the swap — only the model behind it.
|
|
1458
|
+
assert.is(replies.at(-1)?.providerName, 'high');
|
|
1459
|
+
|
|
1460
|
+
driver.dispose();
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
modelAttr('omits the model key entirely when the provider reports no status', async () => {
|
|
1464
|
+
const provider = modelProvider(undefined, [{ role: 'assistant', content: 'no status here' }]);
|
|
1465
|
+
const driver = makeDriver(agent({ name: 'plain' }), provider);
|
|
1466
|
+
|
|
1467
|
+
await driver.sendMessage('hi');
|
|
1468
|
+
|
|
1469
|
+
const reply = driver.getHistory().find((m) => m.role === 'assistant');
|
|
1470
|
+
assert.ok(reply, 'assistant reply present');
|
|
1471
|
+
// No getStatus → no model. The key is left off, not set to undefined, so the
|
|
1472
|
+
// exported log carries no dead `model` line for this message.
|
|
1473
|
+
assert.not.ok('model' in reply!, 'model key is absent, not present-as-undefined');
|
|
1474
|
+
assert.is(reply!.providerName, 'test', 'provider name is still recorded');
|
|
1475
|
+
|
|
1476
|
+
driver.dispose();
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
modelAttr.run();
|
|
@@ -14,7 +14,10 @@ import type {
|
|
|
14
14
|
SubAgentFailureReason,
|
|
15
15
|
SubAgentRequestOptions,
|
|
16
16
|
} from '@genesislcap/foundation-ai';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
isObservableAIProviderRegistry,
|
|
19
|
+
MalformedFunctionCallError,
|
|
20
|
+
} from '@genesislcap/foundation-ai';
|
|
18
21
|
import { agenticActivityBus } from '../../channel/ai-activity-bus';
|
|
19
22
|
import type {
|
|
20
23
|
AgentConfig,
|
|
@@ -339,6 +342,27 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
339
342
|
private lastResolvedProviderName?: string;
|
|
340
343
|
/** Last dispatched `provider-changed` name; avoids duplicate events on stable turns. */
|
|
341
344
|
private lastDispatchedProviderName?: string;
|
|
345
|
+
/**
|
|
346
|
+
* Concrete model id of the provider resolved for the current turn, read from
|
|
347
|
+
* its `getStatus()` and stamped onto the resulting assistant message so the
|
|
348
|
+
* debug log carries per-message model lineage. Re-resolved each turn in
|
|
349
|
+
* `resolveProviderForTurn`.
|
|
350
|
+
*/
|
|
351
|
+
private lastResolvedModel?: string;
|
|
352
|
+
/**
|
|
353
|
+
* Memoised `name → model id` lookups, so the per-turn model resolution doesn't
|
|
354
|
+
* re-await `getStatus()` every turn. Cleared (with `resolvedProviderCache`) on
|
|
355
|
+
* an observable-registry change, so a model swapped behind a stable name is
|
|
356
|
+
* picked up on the next turn.
|
|
357
|
+
*/
|
|
358
|
+
private resolvedModelCache = new Map<string, string | undefined>();
|
|
359
|
+
/**
|
|
360
|
+
* Unsubscribe handle for the provider-registry change listener (only set when
|
|
361
|
+
* the injected registry is observable). Called in `dispose` so the long-lived
|
|
362
|
+
* registry doesn't retain this driver — see the constructor and the sub-agent
|
|
363
|
+
* teardown in `runSubAgent`.
|
|
364
|
+
*/
|
|
365
|
+
private unsubscribeRegistry?: () => void;
|
|
342
366
|
|
|
343
367
|
constructor(
|
|
344
368
|
private readonly providerRegistry: AIProviderRegistry,
|
|
@@ -371,6 +395,24 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
371
395
|
this.primerHistory = primerHistory;
|
|
372
396
|
this.maxFoldOperations = maxFoldOperations;
|
|
373
397
|
this.maxTurnSnapshots = maxTurnSnapshots;
|
|
398
|
+
// Runtime provider switching: when the host registered an observable
|
|
399
|
+
// registry, drop our memoised provider lookups whenever its mapping/default
|
|
400
|
+
// changes so the next turn re-resolves against the new providers. Resetting
|
|
401
|
+
// `lastDispatchedProviderName` forces the next `resolveProviderForTurn` to
|
|
402
|
+
// re-emit `provider-changed` even when the resolved *name* is unchanged
|
|
403
|
+
// (e.g. a tier name like 'high' kept, but the vendor underneath swapped) —
|
|
404
|
+
// that's what lets status UI refresh on a same-name switch. Feature-detect
|
|
405
|
+
// means immutable/empty registries are a no-op. Child sub-agent drivers get
|
|
406
|
+
// the same registry and so subscribe here too; each cleans up in `dispose`.
|
|
407
|
+
if (isObservableAIProviderRegistry(this.providerRegistry)) {
|
|
408
|
+
this.unsubscribeRegistry = this.providerRegistry.subscribe(() => {
|
|
409
|
+
this.resolvedProviderCache.clear();
|
|
410
|
+
this.resolvedModelCache.clear();
|
|
411
|
+
this.lastResolvedProviderName = undefined;
|
|
412
|
+
this.lastResolvedModel = undefined;
|
|
413
|
+
this.lastDispatchedProviderName = undefined;
|
|
414
|
+
});
|
|
415
|
+
}
|
|
374
416
|
}
|
|
375
417
|
|
|
376
418
|
/**
|
|
@@ -390,6 +432,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
390
432
|
*/
|
|
391
433
|
dispose(): void {
|
|
392
434
|
this.lifecycleController.abort(new DOMException('AI assistant driver disposed', 'AbortError'));
|
|
435
|
+
// Detach from the provider registry so the long-lived registry doesn't pin
|
|
436
|
+
// this driver (and its closure) after teardown. Guard-cleared so a second
|
|
437
|
+
// dispose is a no-op, matching this method's idempotent contract.
|
|
438
|
+
this.unsubscribeRegistry?.();
|
|
439
|
+
this.unsubscribeRegistry = undefined;
|
|
393
440
|
}
|
|
394
441
|
|
|
395
442
|
/**
|
|
@@ -540,6 +587,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
540
587
|
resolvedName = name;
|
|
541
588
|
}
|
|
542
589
|
this.lastResolvedProviderName = resolvedName;
|
|
590
|
+
this.lastResolvedModel = await this.resolveModelForProvider(resolvedName, provider);
|
|
543
591
|
if (resolvedName !== this.lastDispatchedProviderName) {
|
|
544
592
|
this.lastDispatchedProviderName = resolvedName;
|
|
545
593
|
recordMetaEvent(this.sessionKey, 'provider.selected', {
|
|
@@ -553,6 +601,27 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
553
601
|
return provider;
|
|
554
602
|
}
|
|
555
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Resolve the concrete model id for a provider via its optional `getStatus()`,
|
|
606
|
+
* memoised by registry name. Used to stamp `model` onto outgoing messages.
|
|
607
|
+
* Best-effort: a provider without `getStatus`, a null status, or a throw all
|
|
608
|
+
* yield `undefined` — model attribution is diagnostic, never load-bearing.
|
|
609
|
+
*/
|
|
610
|
+
private async resolveModelForProvider(
|
|
611
|
+
name: string,
|
|
612
|
+
provider: AIProvider,
|
|
613
|
+
): Promise<string | undefined> {
|
|
614
|
+
if (this.resolvedModelCache.has(name)) return this.resolvedModelCache.get(name);
|
|
615
|
+
let model: string | undefined;
|
|
616
|
+
try {
|
|
617
|
+
model = (await provider.getStatus?.())?.model;
|
|
618
|
+
} catch {
|
|
619
|
+
model = undefined;
|
|
620
|
+
}
|
|
621
|
+
this.resolvedModelCache.set(name, model);
|
|
622
|
+
return model;
|
|
623
|
+
}
|
|
624
|
+
|
|
556
625
|
/**
|
|
557
626
|
* Resolve a per-turn config input that is either a static value or a function
|
|
558
627
|
* of the turn context — the value-or-resolver shape shared by `provider`,
|
|
@@ -921,6 +990,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
921
990
|
component: componentName,
|
|
922
991
|
agent: this.activeAgentName,
|
|
923
992
|
});
|
|
993
|
+
// Signal the park boundary on the activity bus so turn-aware UI can tell
|
|
994
|
+
// "actively computing" from "parked awaiting the user" — the latter is a
|
|
995
|
+
// safe window for actions disallowed mid-request (e.g. switching provider
|
|
996
|
+
// during a long journey step). Paired with `interaction-resolved`.
|
|
997
|
+
agenticActivityBus.publish('interaction-requested', undefined);
|
|
924
998
|
if (chatInputDuringExecution) {
|
|
925
999
|
this.dispatchEvent(
|
|
926
1000
|
new CustomEvent('interaction-start', {
|
|
@@ -989,6 +1063,10 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
989
1063
|
this.dispatchEvent(new CustomEvent('interaction-stop', { detail: { interactionId } }));
|
|
990
1064
|
}
|
|
991
1065
|
recordMetaEvent(this.sessionKey, 'interaction.resolved', { interactionId });
|
|
1066
|
+
// The park is ending and the loop is about to resume computing — paired
|
|
1067
|
+
// with `interaction-requested`. Fires for every resolution path (user
|
|
1068
|
+
// completion, timeout, cancellation), since all route through here.
|
|
1069
|
+
agenticActivityBus.publish('interaction-resolved', undefined);
|
|
992
1070
|
interaction.resolve(result);
|
|
993
1071
|
this.pendingInteractions.delete(interactionId);
|
|
994
1072
|
} else {
|
|
@@ -1262,6 +1340,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1262
1340
|
this.lifecycleController.signal.removeEventListener('abort', disposeChild);
|
|
1263
1341
|
child.removeEventListener('history-updated', forwardTrace);
|
|
1264
1342
|
child.removeEventListener('provider-changed', forwardProviderChanged);
|
|
1343
|
+
// Tear the child down on every exit path, not just timeout/parent-abort.
|
|
1344
|
+
// A child that completes normally is otherwise never disposed, so its
|
|
1345
|
+
// provider-registry subscription (wired in the ChatDriver constructor)
|
|
1346
|
+
// would leak — the long-lived registry would retain every completed
|
|
1347
|
+
// sub-agent driver. dispose() is idempotent and only aborts the (already
|
|
1348
|
+
// settled) lifecycle, so the snapshot/completion reads below still work.
|
|
1349
|
+
child.dispose();
|
|
1265
1350
|
this.dispatchEvent(new CustomEvent('sub-agent-stop', { detail: lifecycleDetail }));
|
|
1266
1351
|
}
|
|
1267
1352
|
|
|
@@ -1767,6 +1852,22 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1767
1852
|
throw e;
|
|
1768
1853
|
}
|
|
1769
1854
|
|
|
1855
|
+
// Attribute the response to the concrete model + registry slot that
|
|
1856
|
+
// produced it (resolved for this turn in `resolveProviderForTurn`). Carried
|
|
1857
|
+
// on the assistant message so the debug-log timeline shows per-message
|
|
1858
|
+
// model lineage — and thus per-tool-call, since tool calls ride on the
|
|
1859
|
+
// assistant message that requests them. Harmless on a response later
|
|
1860
|
+
// discarded as empty/retried; only kept copies reach history.
|
|
1861
|
+
//
|
|
1862
|
+
// Attach each key only when resolved: a provider with no `getStatus` (etc.)
|
|
1863
|
+
// leaves the key off entirely rather than carrying it as `undefined`.
|
|
1864
|
+
// JSON.stringify already drops undefined from the exported log, so this is
|
|
1865
|
+
// chiefly about keeping the in-memory message shape honest.
|
|
1866
|
+
if (this.lastResolvedModel !== undefined) response.model = this.lastResolvedModel;
|
|
1867
|
+
if (this.lastResolvedProviderName !== undefined) {
|
|
1868
|
+
response.providerName = this.lastResolvedProviderName;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1770
1871
|
const isThinkingStep = response.content && response.toolCalls?.length;
|
|
1771
1872
|
const isEmptyResponse = !response.content?.trim() && !response.toolCalls?.length;
|
|
1772
1873
|
|
package/src/main/main.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type {
|
|
|
28
28
|
ChatInputDuringExecutionMode,
|
|
29
29
|
ChatMessage,
|
|
30
30
|
} from '@genesislcap/foundation-ai';
|
|
31
|
-
import { AIProviderRegistry } from '@genesislcap/foundation-ai';
|
|
31
|
+
import { AIProviderRegistry, isObservableAIProviderRegistry } from '@genesislcap/foundation-ai';
|
|
32
32
|
import { avoidTreeShaking } from '@genesislcap/foundation-utils';
|
|
33
33
|
import {
|
|
34
34
|
customElement,
|
|
@@ -592,6 +592,8 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
592
592
|
private driverCleanup?: () => void;
|
|
593
593
|
private loadingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
594
594
|
private unsubBus?: () => void;
|
|
595
|
+
/** Unsubscribe handle for the provider-registry change listener (observable registries only). */
|
|
596
|
+
private unsubProviderRegistry?: () => void;
|
|
595
597
|
private haloStartPublished = false;
|
|
596
598
|
/** Fingerprint of the agents array used to build the current driver. Used by agentsChanged to skip spurious rebuilds. */
|
|
597
599
|
private _driverAgentsKey?: string;
|
|
@@ -1117,6 +1119,21 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
1117
1119
|
this.fetchSuggestions();
|
|
1118
1120
|
void this.resolveContextLimit();
|
|
1119
1121
|
void this.loadProviderStatuses();
|
|
1122
|
+
// When the host registered an observable registry (runtime provider
|
|
1123
|
+
// switching), refresh the displayed model/limit and the provider list the
|
|
1124
|
+
// instant its contents change — so the header reflects the new provider
|
|
1125
|
+
// immediately on switch, not only when the next turn re-emits
|
|
1126
|
+
// `provider-changed`. Feature-detected: a no-op for immutable registries.
|
|
1127
|
+
// Re-subscribed per connect (docking/popout remounts); balanced in
|
|
1128
|
+
// disconnectedCallback.
|
|
1129
|
+
this.unsubProviderRegistry?.();
|
|
1130
|
+
this.unsubProviderRegistry = undefined;
|
|
1131
|
+
if (isObservableAIProviderRegistry(this.providerRegistry)) {
|
|
1132
|
+
this.unsubProviderRegistry = this.providerRegistry.subscribe(() => {
|
|
1133
|
+
void this.resolveContextLimit();
|
|
1134
|
+
void this.loadProviderStatuses();
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1120
1137
|
if (this.messagesEl) {
|
|
1121
1138
|
this._scrollListener = () => {
|
|
1122
1139
|
this._userScrolledAway =
|
|
@@ -1150,6 +1167,8 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
1150
1167
|
this.unwireDriver();
|
|
1151
1168
|
this.unsubBus?.();
|
|
1152
1169
|
this.unsubBus = undefined;
|
|
1170
|
+
this.unsubProviderRegistry?.();
|
|
1171
|
+
this.unsubProviderRegistry = undefined;
|
|
1153
1172
|
this._executionCompletionUnsub?.();
|
|
1154
1173
|
this._executionCompletionUnsub = undefined;
|
|
1155
1174
|
if (this.messagesEl && this._scrollListener) {
|
|
@@ -247,7 +247,7 @@ export const DEBUG_LOG_README: readonly string[] = [
|
|
|
247
247
|
'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',
|
|
248
248
|
'`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',
|
|
249
249
|
'Timestamps are millisecond-resolution; entries that share the same millisecond are ordered by a fixed kind rank (event, then turn, then message), which is a heuristic and may not reflect exact causal order within that millisecond — e.g. a user message and the turn it triggered, or a final assistant message and its turn.end event, can appear in either order depending on whether they landed in the same millisecond. Read the logical structure of a turn rather than over-interpreting the micro-ordering of co-timestamped entries of different kinds.',
|
|
250
|
-
"kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, and `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.",
|
|
250
|
+
"kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, and `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). On model-produced assistant messages, `model` is the concrete model id that generated it (e.g. 'gemini-2.5-flash-lite') and `providerName` is the registry slot it resolved under (e.g. a tier name like 'high'/'low', or the default); together they attribute the message — and any tool calls it carries — to an exact model even across a mid-session vendor/tier switch, where one slot name can map to different models before and after the switch. Both are undefined on any entry that is NOT an LLM response: non-assistant roles (user/tool/system-event) and 'synthetic-user' echoes; assistant interaction/widget entries (empty content carrying an `interaction` — a rendered widget, not a model turn); driver-authored assistant fallbacks (the timeout, repeated-malformed-call, and empty-response apology messages); and messages restored from a session persisted before these fields existed. One partial case: on a genuine model turn whose provider exposes no `getStatus` (or reports no model), `providerName` is still set but `model` alone is undefined. A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.",
|
|
251
251
|
"kind:'turn' — one LLM call. `turnIndex` is a string: a top-level turn is the bare counter ('0', '1', …); a sub-agent's turns are numbered under the parent turn that activated them ('3-1', '3-2', …, and a nested sub-agent contributes '3-2-1', …), and `agentName` names the agent that ran the turn. `systemPrompt` and `toolNames` are what the model saw. A systemPrompt of '<repeated — identical to turn N>' was byte-identical to turn N and de-duplicated; the full prompt is shown whenever it changes (often because a stateful agent advanced), so prompt evolution is visible.",
|
|
252
252
|
"kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
|
|
253
253
|
"kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
|