@genesislcap/ai-assistant 14.497.0 → 14.499.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 +132 -0
- package/dist/ai-assistant.d.ts +35 -0
- package/dist/chat-driver.cjs +157 -0
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +151 -0
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +156 -1
- package/dist/dts/chat-driver-node.d.ts +7 -1
- package/dist/dts/chat-driver-node.d.ts.map +1 -1
- package/dist/dts/components/ai-driver/ai-driver.d.ts +8 -0
- package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +27 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +3 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts +1 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/state/persistence/build-timeline-entries.d.ts +37 -0
- package/dist/dts/state/persistence/build-timeline-entries.d.ts.map +1 -0
- package/dist/esm/chat-driver-node.js +16 -1
- package/dist/esm/components/chat-driver/chat-driver.js +103 -0
- package/dist/esm/components/chat-driver/chat-driver.test.js +165 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +4 -0
- package/dist/esm/main/main.js +18 -28
- package/dist/esm/state/debug-event-log.js +1 -0
- package/dist/esm/state/persistence/build-timeline-entries.js +46 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/chat-driver-node.ts +20 -0
- package/src/components/ai-driver/ai-driver.ts +9 -0
- package/src/components/chat-driver/chat-driver.test.ts +215 -0
- package/src/components/chat-driver/chat-driver.ts +109 -0
- package/src/components/orchestrating-driver/orchestrating-driver.ts +6 -0
- package/src/main/main.ts +17 -30
- package/src/state/debug-event-log.ts +3 -0
- package/src/state/persistence/build-timeline-entries.ts +66 -0
|
@@ -1462,6 +1462,221 @@ interactionPresentation('presentation is absent when the option is omitted', asy
|
|
|
1462
1462
|
|
|
1463
1463
|
interactionPresentation.run();
|
|
1464
1464
|
|
|
1465
|
+
// ---------------------------------------------------------------------------
|
|
1466
|
+
// external diagnostics harvest (GENC-1461) — a widget may return an out-of-band
|
|
1467
|
+
// engine's collated debug log on `InteractionResult.diagnostics` (e.g. a
|
|
1468
|
+
// server-side ChatDriver's). Those entries carry the SOURCE engine's own 0-based
|
|
1469
|
+
// index space, which would collide with this host's forward-capture cursors and
|
|
1470
|
+
// be dropped: the persister dedups `turn` entries by `turnIndex` and `event`
|
|
1471
|
+
// entries by an `index` high-water mark. So the driver decomposes the harvest by
|
|
1472
|
+
// kind — events fold into the session's meta-event registry (re-indexed onto the
|
|
1473
|
+
// host's monotonic counter), turns are re-keyed into a per-batch namespace, and
|
|
1474
|
+
// messages pass through unchanged (the persister already keys those by content).
|
|
1475
|
+
// ---------------------------------------------------------------------------
|
|
1476
|
+
|
|
1477
|
+
const externalDiagnostics = createLogicSuite('ChatDriver external diagnostics harvest');
|
|
1478
|
+
|
|
1479
|
+
/** A `diagnostics` payload as a headless engine would collate it — one of each kind,
|
|
1480
|
+
* all carrying that engine's own 0-based indices (`turnIndex: '0'`, event `index: 0`). */
|
|
1481
|
+
const harvestPayload = (marker: string) => [
|
|
1482
|
+
{ kind: 'message', timestamp: '2026-07-23T10:00:00.000Z', role: 'assistant', content: marker },
|
|
1483
|
+
{ kind: 'turn', timestamp: '2026-07-23T10:00:01.000Z', turnIndex: '0', systemPrompt: 'sys' },
|
|
1484
|
+
{
|
|
1485
|
+
kind: 'event',
|
|
1486
|
+
index: 0,
|
|
1487
|
+
timestamp: '2026-07-23T10:00:02.000Z',
|
|
1488
|
+
type: 'turn.error',
|
|
1489
|
+
importance: 'high',
|
|
1490
|
+
detail: { marker },
|
|
1491
|
+
},
|
|
1492
|
+
];
|
|
1493
|
+
|
|
1494
|
+
externalDiagnostics(
|
|
1495
|
+
'routes harvested events to the meta-event registry (re-indexed) and keeps turns + messages',
|
|
1496
|
+
async () => {
|
|
1497
|
+
clearMetaEventRegistry();
|
|
1498
|
+
const sessionKey = 'external-diag-harvest';
|
|
1499
|
+
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), sessionKey);
|
|
1500
|
+
|
|
1501
|
+
// Requesting the interaction records a host `interaction.requested` meta-event, so the
|
|
1502
|
+
// registry's index counter is already advanced when the harvested event is merged — exactly
|
|
1503
|
+
// the condition that made the foreign 0-based `index` lose the persister's high-water race.
|
|
1504
|
+
const pending = driver.requestInteraction('w', {});
|
|
1505
|
+
const { interactionId } = driver.getHistory().at(-1)!.interaction!;
|
|
1506
|
+
const requestedIndex = getMetaEvents(sessionKey).find(
|
|
1507
|
+
(e) => e.type === 'interaction.requested',
|
|
1508
|
+
)!.index;
|
|
1509
|
+
|
|
1510
|
+
driver.resolveInteraction(interactionId, {
|
|
1511
|
+
status: 'approved',
|
|
1512
|
+
diagnostics: harvestPayload('m1'),
|
|
1513
|
+
});
|
|
1514
|
+
await pending;
|
|
1515
|
+
|
|
1516
|
+
// The event left the external buffer and landed in the registry, re-indexed ABOVE the host's
|
|
1517
|
+
// prior event (so it beats the persister's `index <= lastEventIndex` high-water), timestamp kept.
|
|
1518
|
+
const external = driver.getExternalDiagnostics();
|
|
1519
|
+
assert.is(
|
|
1520
|
+
external.filter((e) => e.kind === 'event').length,
|
|
1521
|
+
0,
|
|
1522
|
+
'events do not stay in the buffer',
|
|
1523
|
+
);
|
|
1524
|
+
const merged = getMetaEvents(sessionKey).find((e) => e.detail?.marker === 'm1');
|
|
1525
|
+
assert.ok(merged, 'the harvested event is folded into the session registry');
|
|
1526
|
+
assert.ok(
|
|
1527
|
+
merged!.index > requestedIndex,
|
|
1528
|
+
're-indexed onto the host counter, above prior events',
|
|
1529
|
+
);
|
|
1530
|
+
assert.is(
|
|
1531
|
+
merged!.timestamp,
|
|
1532
|
+
'2026-07-23T10:00:02.000Z',
|
|
1533
|
+
'original timestamp preserved for ordering',
|
|
1534
|
+
);
|
|
1535
|
+
|
|
1536
|
+
// The turn is kept in the buffer but re-keyed out of the host's bare-integer turn-key space,
|
|
1537
|
+
// and the message passes through untouched.
|
|
1538
|
+
const turn = external.find((e) => e.kind === 'turn');
|
|
1539
|
+
assert.ok(turn, 'the harvested turn is kept in the external buffer');
|
|
1540
|
+
assert.is(turn!.turnIndex, 'server-generation.1:0', 'turnIndex namespaced per batch');
|
|
1541
|
+
const message = external.find((e) => e.kind === 'message');
|
|
1542
|
+
assert.ok(message, 'the harvested message is kept in the external buffer');
|
|
1543
|
+
assert.is(message!.content, 'm1', 'message passes through unchanged');
|
|
1544
|
+
|
|
1545
|
+
// A single manifest event records the fold's provenance — the fragile boundary this feature
|
|
1546
|
+
// guards is now observable: a dropped generation shows up as a zero/absent manifest.
|
|
1547
|
+
const manifest = getMetaEvents(sessionKey).find(
|
|
1548
|
+
(e) => e.type === 'external-diagnostics.folded',
|
|
1549
|
+
);
|
|
1550
|
+
assert.ok(manifest, 'a manifest meta-event is recorded for the fold');
|
|
1551
|
+
assert.is(manifest!.detail?.interactionId, interactionId);
|
|
1552
|
+
assert.equal(manifest!.detail?.counts, { turn: 1, event: 1, message: 1 });
|
|
1553
|
+
},
|
|
1554
|
+
);
|
|
1555
|
+
|
|
1556
|
+
externalDiagnostics(
|
|
1557
|
+
'namespaces each batch so distinct server generations cannot collide on turnIndex',
|
|
1558
|
+
async () => {
|
|
1559
|
+
clearMetaEventRegistry();
|
|
1560
|
+
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), 'external-diag-batches');
|
|
1561
|
+
|
|
1562
|
+
const first = driver.requestInteraction('w', {});
|
|
1563
|
+
const firstId = driver.getHistory().at(-1)!.interaction!.interactionId;
|
|
1564
|
+
driver.resolveInteraction(firstId, { status: 'approved', diagnostics: harvestPayload('a') });
|
|
1565
|
+
await first;
|
|
1566
|
+
|
|
1567
|
+
const second = driver.requestInteraction('w', {});
|
|
1568
|
+
const secondId = driver.getHistory().at(-1)!.interaction!.interactionId;
|
|
1569
|
+
driver.resolveInteraction(secondId, { status: 'approved', diagnostics: harvestPayload('b') });
|
|
1570
|
+
await second;
|
|
1571
|
+
|
|
1572
|
+
// Both harvests carried `turnIndex: '0'`; the per-batch namespace keeps them distinct, so the
|
|
1573
|
+
// persister's `emittedTurnKeys` can't fold the second generation's turn into the first.
|
|
1574
|
+
const turnKeys = driver
|
|
1575
|
+
.getExternalDiagnostics()
|
|
1576
|
+
.filter((e) => e.kind === 'turn')
|
|
1577
|
+
.map((e) => e.turnIndex);
|
|
1578
|
+
assert.equal(turnKeys, ['server-generation.1:0', 'server-generation.2:0']);
|
|
1579
|
+
},
|
|
1580
|
+
);
|
|
1581
|
+
|
|
1582
|
+
externalDiagnostics(
|
|
1583
|
+
'keys turns by local position so several server runs in one harvest do not collide',
|
|
1584
|
+
async () => {
|
|
1585
|
+
clearMetaEventRegistry();
|
|
1586
|
+
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), 'external-diag-multirun');
|
|
1587
|
+
|
|
1588
|
+
// One interaction carrying two independent server runs (e.g. a consolidator's consolidator-code
|
|
1589
|
+
// run and its table-code run) — each numbers its own turns from 0, so the harvest has two
|
|
1590
|
+
// `turnIndex: '0'` entries. Keying by source index would collapse them; local position keeps
|
|
1591
|
+
// both, so the persister's per-turnIndex dedup drops neither.
|
|
1592
|
+
const pending = driver.requestInteraction('w', {});
|
|
1593
|
+
const { interactionId } = driver.getHistory().at(-1)!.interaction!;
|
|
1594
|
+
driver.resolveInteraction(interactionId, {
|
|
1595
|
+
status: 'approved',
|
|
1596
|
+
diagnostics: [
|
|
1597
|
+
{
|
|
1598
|
+
kind: 'turn',
|
|
1599
|
+
timestamp: '2026-07-23T10:00:00.000Z',
|
|
1600
|
+
turnIndex: '0',
|
|
1601
|
+
systemPrompt: 'run1',
|
|
1602
|
+
},
|
|
1603
|
+
{
|
|
1604
|
+
kind: 'turn',
|
|
1605
|
+
timestamp: '2026-07-23T10:00:01.000Z',
|
|
1606
|
+
turnIndex: '1',
|
|
1607
|
+
systemPrompt: 'run1',
|
|
1608
|
+
},
|
|
1609
|
+
{
|
|
1610
|
+
kind: 'turn',
|
|
1611
|
+
timestamp: '2026-07-23T10:00:02.000Z',
|
|
1612
|
+
turnIndex: '0',
|
|
1613
|
+
systemPrompt: 'run2',
|
|
1614
|
+
},
|
|
1615
|
+
],
|
|
1616
|
+
});
|
|
1617
|
+
await pending;
|
|
1618
|
+
|
|
1619
|
+
const turnKeys = driver
|
|
1620
|
+
.getExternalDiagnostics()
|
|
1621
|
+
.filter((e) => e.kind === 'turn')
|
|
1622
|
+
.map((e) => e.turnIndex);
|
|
1623
|
+
assert.equal(turnKeys, [
|
|
1624
|
+
'server-generation.1:0',
|
|
1625
|
+
'server-generation.1:1',
|
|
1626
|
+
'server-generation.1:2',
|
|
1627
|
+
]);
|
|
1628
|
+
// All distinct — no key repeats despite the repeated source `turnIndex: '0'`.
|
|
1629
|
+
assert.is(new Set(turnKeys).size, 3, 'every harvested turn gets a distinct key');
|
|
1630
|
+
},
|
|
1631
|
+
);
|
|
1632
|
+
|
|
1633
|
+
externalDiagnostics(
|
|
1634
|
+
'tolerates malformed entries and does not fold or miscount non-standard kinds',
|
|
1635
|
+
async () => {
|
|
1636
|
+
clearMetaEventRegistry();
|
|
1637
|
+
const sessionKey = 'external-diag-malformed';
|
|
1638
|
+
const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), sessionKey);
|
|
1639
|
+
|
|
1640
|
+
const pending = driver.requestInteraction('w', {});
|
|
1641
|
+
const { interactionId } = driver.getHistory().at(-1)!.interaction!;
|
|
1642
|
+
// The harvest is untrusted `unknown[]`: primitives/null and an object without a string `kind`
|
|
1643
|
+
// must be skipped (not throw), and a `meta-snapshot` (host-owned, never emitted by a sibling)
|
|
1644
|
+
// must be dropped rather than folded or miscounted as a message.
|
|
1645
|
+
driver.resolveInteraction(interactionId, {
|
|
1646
|
+
status: 'approved',
|
|
1647
|
+
diagnostics: [
|
|
1648
|
+
null,
|
|
1649
|
+
'oops',
|
|
1650
|
+
42,
|
|
1651
|
+
{ timestamp: '2026-07-23T10:00:00.000Z' }, // no kind
|
|
1652
|
+
{
|
|
1653
|
+
kind: 'message',
|
|
1654
|
+
timestamp: '2026-07-23T10:00:01.000Z',
|
|
1655
|
+
role: 'assistant',
|
|
1656
|
+
content: 'real',
|
|
1657
|
+
},
|
|
1658
|
+
{ kind: 'meta-snapshot', timestamp: '2026-07-23T10:00:02.000Z', meta: { host: 'foreign' } },
|
|
1659
|
+
],
|
|
1660
|
+
});
|
|
1661
|
+
await pending;
|
|
1662
|
+
|
|
1663
|
+
// Only the one real message survives the buffer; the meta-snapshot is not folded.
|
|
1664
|
+
const external = driver.getExternalDiagnostics();
|
|
1665
|
+
assert.equal(
|
|
1666
|
+
external.map((e) => e.kind),
|
|
1667
|
+
['message'],
|
|
1668
|
+
);
|
|
1669
|
+
assert.is(external[0].content, 'real');
|
|
1670
|
+
// Manifest counts only the three real kinds — the meta-snapshot is not miscounted as a message.
|
|
1671
|
+
const manifest = getMetaEvents(sessionKey).find(
|
|
1672
|
+
(e) => e.type === 'external-diagnostics.folded',
|
|
1673
|
+
);
|
|
1674
|
+
assert.equal(manifest!.detail?.counts, { turn: 0, event: 0, message: 1 });
|
|
1675
|
+
},
|
|
1676
|
+
);
|
|
1677
|
+
|
|
1678
|
+
externalDiagnostics.run();
|
|
1679
|
+
|
|
1465
1680
|
// ---------------------------------------------------------------------------
|
|
1466
1681
|
// interaction context lifecycle (GENC-1390) — the driver creates a live
|
|
1467
1682
|
// InteractionContext on requestInteraction and disposes+drops it on resolve, so
|
|
@@ -44,6 +44,7 @@ import { resolveChatProvider } from '../../config/validate-providers';
|
|
|
44
44
|
import {
|
|
45
45
|
clearSession,
|
|
46
46
|
getMetaEvents,
|
|
47
|
+
type MetaEvent,
|
|
47
48
|
mergeMetaEvents,
|
|
48
49
|
type MetaEventType,
|
|
49
50
|
recordMetaEvent,
|
|
@@ -54,6 +55,7 @@ import {
|
|
|
54
55
|
createInteractionContext,
|
|
55
56
|
type InteractionContextHandle,
|
|
56
57
|
} from '../../state/interaction-context';
|
|
58
|
+
import type { DiagnosticEntry } from '../../state/persistence/diagnostics';
|
|
57
59
|
import type { InteractionContext } from '../../types/interaction-context';
|
|
58
60
|
import { applyCondensation, type RegisteredCondensePolicy } from '../../utils/condense-history';
|
|
59
61
|
import {
|
|
@@ -435,6 +437,23 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
435
437
|
* arrive. See {@link TurnSnapshot} for the captured shape.
|
|
436
438
|
*/
|
|
437
439
|
private turnSnapshots: TurnSnapshot[] = [];
|
|
440
|
+
/**
|
|
441
|
+
* `turn` + `message` diagnostic-log entries harvested from an out-of-band source (a resolved
|
|
442
|
+
* interaction whose widget returned `InteractionResult.diagnostics` — e.g. a server-side
|
|
443
|
+
* ChatDriver's debug log). `event` entries are NOT kept here — they're folded into the session's
|
|
444
|
+
* meta-event registry via `mergeMetaEvents` (see `resolveInteraction`) so they inherit fresh host
|
|
445
|
+
* indices, and reach the log through `getMetaEvents`. Append-only; the host folds these into its
|
|
446
|
+
* debug log alongside this driver's own timeline. Not cleared here — it lives with the driver
|
|
447
|
+
* instance, like `turnSnapshots`, so a new chat (fresh driver) starts empty while a compaction
|
|
448
|
+
* (same instance) keeps it. (GENC-1461.)
|
|
449
|
+
*/
|
|
450
|
+
private readonly externalDiagnostics: DiagnosticEntry[] = [];
|
|
451
|
+
/**
|
|
452
|
+
* Monotonic count of external-diagnostics batches harvested this driver-lifetime. Namespaces each
|
|
453
|
+
* batch's re-keyed `turn` entries so distinct server generations in one chat session can't collide
|
|
454
|
+
* on `turnIndex` in the forward-capture persister (which dedups turns by that key). (GENC-1461.)
|
|
455
|
+
*/
|
|
456
|
+
private externalDiagnosticsBatches = 0;
|
|
438
457
|
/** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
|
|
439
458
|
private globalTurnIndex = 0;
|
|
440
459
|
/** Captured from `applyAgent` so we don't store the whole `AgentConfig`. */
|
|
@@ -971,6 +990,18 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
971
990
|
return this.turnSnapshots;
|
|
972
991
|
}
|
|
973
992
|
|
|
993
|
+
/**
|
|
994
|
+
* The `turn` + `message` diagnostic-log entries harvested from resolved interactions'
|
|
995
|
+
* `InteractionResult.diagnostics` (e.g. a server-side ChatDriver's collated debug log). The host
|
|
996
|
+
* concatenates these into its own debug log so `downloadDebugLog`/the persisted diagnostics stream
|
|
997
|
+
* include external-engine logs; `event` entries are excluded here because they're merged into the
|
|
998
|
+
* session's meta-event registry instead (see `resolveInteraction`) and reach the log via
|
|
999
|
+
* `getMetaEvents`. Opaque, chronologically sorted by the host at assemble time. (GENC-1461.)
|
|
1000
|
+
*/
|
|
1001
|
+
getExternalDiagnostics(): ReadonlyArray<DiagnosticEntry> {
|
|
1002
|
+
return this.externalDiagnostics;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
974
1005
|
/**
|
|
975
1006
|
* Merge a sub-agent's turn snapshots into this driver's buffer so they surface
|
|
976
1007
|
* as `kind:'turn'` entries in the exported debug log. The child runs as a
|
|
@@ -1391,6 +1422,84 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1391
1422
|
// Clear the timeout so a user resolution doesn't later fire a stale
|
|
1392
1423
|
// timeout (and a timeout firing here clears its own already-fired handle).
|
|
1393
1424
|
if (interaction.timeoutHandle) clearTimeout(interaction.timeoutHandle);
|
|
1425
|
+
// Harvest any out-of-band diagnostic entries the widget collated (e.g. from a server-side
|
|
1426
|
+
// ChatDriver) so the host's debug log + persisted diagnostics include them. Independent of the
|
|
1427
|
+
// message `idx` below — they describe the widget's own work, not this message.
|
|
1428
|
+
//
|
|
1429
|
+
// The harvested entries carry the SOURCE engine's own index space (0-based turns/events), which
|
|
1430
|
+
// would collide with this host's cursors in the forward-capture persister — turns dedup by
|
|
1431
|
+
// `turnIndex`, events by an `index` high-water mark — so foreign entries get silently dropped
|
|
1432
|
+
// from the persisted stream. Route each kind through the host's own collision-safe machinery,
|
|
1433
|
+
// mirroring how a sub-agent's log is folded in (see `invokeSubAgent`):
|
|
1434
|
+
// • `event` → `mergeMetaEvents`, which re-indexes them onto this session's monotonic event
|
|
1435
|
+
// counter (clearing the high-water) while preserving their timestamps; they then
|
|
1436
|
+
// reach the log via `getMetaEvents` in `buildTimelineEntries`, not this buffer.
|
|
1437
|
+
// • `turn` → re-key `turnIndex` to the turn's local position within a per-batch namespace,
|
|
1438
|
+
// so turns stay unique even when one interaction folds in several independent
|
|
1439
|
+
// server runs that each number their own turns from 0 (a consolidator carries a
|
|
1440
|
+
// consolidator-code run AND a table-code run), and never collide with the host's
|
|
1441
|
+
// own bare-integer turn keys.
|
|
1442
|
+
// • `message` → kept as-is; the persister already keys messages by content, so they survive.
|
|
1443
|
+
// TODO(GENC-1461, option 2 / streaming): expose an imperative `appendDiagnostics(entries)` on
|
|
1444
|
+
// the handler context (see buildHandlerContext) that runs this same decomposition as SSE ticks
|
|
1445
|
+
// arrive, so a streaming widget can surface its external engine's log live rather than only at
|
|
1446
|
+
// resolve time. Batch-at-resolve (here) is the current, non-streaming path.
|
|
1447
|
+
const harvestedDiagnostics = (result as { diagnostics?: readonly unknown[] } | undefined)
|
|
1448
|
+
?.diagnostics;
|
|
1449
|
+
if (Array.isArray(harvestedDiagnostics) && harvestedDiagnostics.length) {
|
|
1450
|
+
const batch = (this.externalDiagnosticsBatches += 1);
|
|
1451
|
+
const externalEvents: MetaEvent[] = [];
|
|
1452
|
+
let turnCount = 0;
|
|
1453
|
+
let messageCount = 0;
|
|
1454
|
+
for (const raw of harvestedDiagnostics) {
|
|
1455
|
+
// The harvest is external and typed `unknown[]`, so an element could be a primitive, null,
|
|
1456
|
+
// or an object without a string `kind`. Guard before reading `.kind` so a malformed entry
|
|
1457
|
+
// can't throw here (and gets skipped rather than derailing the whole fold).
|
|
1458
|
+
if (
|
|
1459
|
+
!raw ||
|
|
1460
|
+
typeof raw !== 'object' ||
|
|
1461
|
+
typeof (raw as { kind?: unknown }).kind !== 'string'
|
|
1462
|
+
) {
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
const entry = raw as DiagnosticEntry;
|
|
1466
|
+
if (entry.kind === 'event') {
|
|
1467
|
+
const { kind: _kind, ...event } = entry;
|
|
1468
|
+
externalEvents.push(event as unknown as MetaEvent);
|
|
1469
|
+
} else if (entry.kind === 'turn') {
|
|
1470
|
+
// Local position within THIS harvest, not the source engine's own `turnIndex`: one
|
|
1471
|
+
// interaction can carry several independent server runs (a consolidator folds in its
|
|
1472
|
+
// consolidator-code run and its table-code run), each numbering its turns from 0, so
|
|
1473
|
+
// keying by the source index would collide them. The local position keeps every turn key
|
|
1474
|
+
// distinct within the batch, so the persister's per-`turnIndex` dedup keeps them all.
|
|
1475
|
+
this.externalDiagnostics.push({
|
|
1476
|
+
...entry,
|
|
1477
|
+
turnIndex: `server-generation.${batch}:${turnCount}`,
|
|
1478
|
+
});
|
|
1479
|
+
turnCount += 1;
|
|
1480
|
+
} else if (entry.kind === 'message') {
|
|
1481
|
+
messageCount += 1;
|
|
1482
|
+
this.externalDiagnostics.push(entry);
|
|
1483
|
+
}
|
|
1484
|
+
// Any other kind is intentionally dropped, not folded. A sibling's `buildTimelineEntries`
|
|
1485
|
+
// only ever emits message/turn/event; `meta-snapshot` is host/DOM-owned (latest-wins on
|
|
1486
|
+
// reassembly, plus its own persister dedup), so folding a foreign one would pollute this
|
|
1487
|
+
// host's timeline and meta cursor. Restricting to the three real kinds also keeps the
|
|
1488
|
+
// manifest counts accurate (nothing miscounted as a `message`).
|
|
1489
|
+
}
|
|
1490
|
+
if (externalEvents.length) mergeMetaEvents(this.sessionKey, externalEvents);
|
|
1491
|
+
// Manifest: one provenance marker for the fold itself — records that an out-of-band engine's
|
|
1492
|
+
// log crossed into this session and how much of each kind, so a regression that silently
|
|
1493
|
+
// drops a generation's diagnostics (e.g. a new path that forgets to thread them across the
|
|
1494
|
+
// boundary) surfaces as a zero/absent manifest rather than only by diffing a download. Also
|
|
1495
|
+
// the natural seed for the streaming PR's open/close bracket. Stamped at resolve, so it
|
|
1496
|
+
// closes the block on the sorted timeline. (GENC-1461.)
|
|
1497
|
+
recordMetaEvent(this.sessionKey, 'external-diagnostics.folded', {
|
|
1498
|
+
interactionId,
|
|
1499
|
+
batch,
|
|
1500
|
+
counts: { turn: turnCount, event: externalEvents.length, message: messageCount },
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1394
1503
|
const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
|
|
1395
1504
|
if (idx !== -1) {
|
|
1396
1505
|
// Fold any widget-reported external (non-LLM) cost onto the message, so
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from '../../config/config';
|
|
16
16
|
import { validateStaticAgentProviders } from '../../config/validate-providers';
|
|
17
17
|
import { recordMetaEvent } from '../../state/debug-event-log';
|
|
18
|
+
import type { DiagnosticEntry } from '../../state/persistence/diagnostics';
|
|
18
19
|
import type { InteractionContext } from '../../types/interaction-context';
|
|
19
20
|
import { transformHistoryForAgent } from '../../utils/history-transform';
|
|
20
21
|
import { logger } from '../../utils/logger';
|
|
@@ -269,6 +270,11 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
|
|
|
269
270
|
return this.chatDriver.getTurnSnapshots();
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
/** Delegates to the inner {@link ChatDriver} — interactions resolve there, so it holds the buffer. */
|
|
274
|
+
getExternalDiagnostics(): ReadonlyArray<DiagnosticEntry> {
|
|
275
|
+
return this.chatDriver.getExternalDiagnostics();
|
|
276
|
+
}
|
|
277
|
+
|
|
272
278
|
async getSuggestions(
|
|
273
279
|
history: ChatMessage[],
|
|
274
280
|
prompt: string,
|
package/src/main/main.ts
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
getDriverAgentsKey,
|
|
75
75
|
deleteDriver,
|
|
76
76
|
} from '../state/driver-registry';
|
|
77
|
+
import { buildTimelineEntries } from '../state/persistence/build-timeline-entries';
|
|
77
78
|
import { assembleDebugLog } from '../state/persistence/diagnostics';
|
|
78
79
|
import type { DebugLog, DiagnosticEntry } from '../state/persistence/diagnostics';
|
|
79
80
|
import {
|
|
@@ -122,7 +123,6 @@ import {
|
|
|
122
123
|
deriveCostSessionTitleFromMessages,
|
|
123
124
|
resolveCostSessionTitle,
|
|
124
125
|
} from '../utils/derive-cost-session-title';
|
|
125
|
-
import { flattenSubAgentMessages } from '../utils/flatten-sub-agent-messages';
|
|
126
126
|
import { logger } from '../utils/logger';
|
|
127
127
|
import { filterVisibleMessages, trailingInteractionRow } from '../utils/message-partition';
|
|
128
128
|
import {
|
|
@@ -2698,36 +2698,15 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
2698
2698
|
: undefined;
|
|
2699
2699
|
const stateKey = this.getStateKey();
|
|
2700
2700
|
|
|
2701
|
-
//
|
|
2702
|
-
//
|
|
2703
|
-
//
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
let lastFullIndex = '';
|
|
2709
|
-
const turns = (this.driver?.getTurnSnapshots?.() ?? []).map((t) => {
|
|
2710
|
-
let { systemPrompt } = t;
|
|
2711
|
-
if (systemPrompt != null && systemPrompt === lastFullPrompt) {
|
|
2712
|
-
systemPrompt = `<repeated — identical to turn ${lastFullIndex}>`;
|
|
2713
|
-
} else if (systemPrompt != null) {
|
|
2714
|
-
lastFullPrompt = systemPrompt;
|
|
2715
|
-
lastFullIndex = t.turnIndex;
|
|
2716
|
-
}
|
|
2717
|
-
return { kind: 'turn' as const, ...t, systemPrompt };
|
|
2701
|
+
// The message/turn/event timeline entries — built by the shared, pure `buildTimelineEntries`
|
|
2702
|
+
// (the same helper a headless consumer uses to harvest its own log), from the driver's pull
|
|
2703
|
+
// surfaces. Prefer the driver's raw history (carries sub-agent traces) over the redux projection.
|
|
2704
|
+
const timelineEntries = buildTimelineEntries({
|
|
2705
|
+
turnSnapshots: this.driver?.getTurnSnapshots?.() ?? [],
|
|
2706
|
+
messages: this.driver?.getRawHistory?.() ?? this.messages,
|
|
2707
|
+
metaEvents: stateKey ? getMetaEvents(stateKey) : [],
|
|
2718
2708
|
});
|
|
2719
2709
|
|
|
2720
|
-
// Sub-agent conversations are stored nested on the parent tool call's
|
|
2721
|
-
// `subAgentTrace`; `flattenSubAgentMessages` hoists them to top-level
|
|
2722
|
-
// `kind: 'message'` entries (breadcrumbed + correlated, the nested copy moved
|
|
2723
|
-
// out — not duplicated) so the timeline reads as one chronological sequence.
|
|
2724
|
-
const messages = this.driver?.getRawHistory?.() ?? this.messages;
|
|
2725
|
-
const messageEntries = flattenSubAgentMessages(messages);
|
|
2726
|
-
const eventEntries = (stateKey ? getMetaEvents(stateKey) : []).map((e) => ({
|
|
2727
|
-
kind: 'event' as const,
|
|
2728
|
-
...e,
|
|
2729
|
-
}));
|
|
2730
|
-
|
|
2731
2710
|
// The export-time `meta` block, carried on a `meta-snapshot` entry so it lives
|
|
2732
2711
|
// in the same forward stream (the latest one wins on reassembly, and the
|
|
2733
2712
|
// accumulated history exposes config/state evolution across the lifetime).
|
|
@@ -2797,7 +2776,15 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
2797
2776
|
activeFoldStack: m.activeFoldStack,
|
|
2798
2777
|
});
|
|
2799
2778
|
|
|
2800
|
-
|
|
2779
|
+
// Fold in any external diagnostics harvested from an out-of-band driver (e.g. a server-side
|
|
2780
|
+
// ChatDriver whose collated log an interaction widget returned on its result). They ride the
|
|
2781
|
+
// same download + persisted-diagnostics path; `assembleDebugLog` sorts the whole timeline by
|
|
2782
|
+
// timestamp so they interleave chronologically. (GENC-1461 unified diagnostics.)
|
|
2783
|
+
return [
|
|
2784
|
+
...timelineEntries,
|
|
2785
|
+
...(this.driver?.getExternalDiagnostics?.() ?? []),
|
|
2786
|
+
metaSnapshot,
|
|
2787
|
+
] as DiagnosticEntry[];
|
|
2801
2788
|
}
|
|
2802
2789
|
|
|
2803
2790
|
async downloadDebugLog(): Promise<void> {
|
|
@@ -59,6 +59,8 @@ export type MetaEventType =
|
|
|
59
59
|
// Blocking interactions
|
|
60
60
|
| 'interaction.requested'
|
|
61
61
|
| 'interaction.resolved'
|
|
62
|
+
// External / unified diagnostics (GENC-1461) — an out-of-band engine's log folded into this session
|
|
63
|
+
| 'external-diagnostics.folded'
|
|
62
64
|
// Context / cost
|
|
63
65
|
| 'context.updated'
|
|
64
66
|
| 'context.threshold-crossed'
|
|
@@ -125,6 +127,7 @@ export const META_EVENT_IMPORTANCE: Record<MetaEventType, MetaEventImportance> =
|
|
|
125
127
|
'provider.selected': 'normal',
|
|
126
128
|
'interaction.requested': 'normal',
|
|
127
129
|
'interaction.resolved': 'normal',
|
|
130
|
+
'external-diagnostics.folded': 'normal',
|
|
128
131
|
'session.cleared': 'normal',
|
|
129
132
|
'session.switched': 'low',
|
|
130
133
|
'session.switch-deferred': 'low',
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, pure builder for the debug-log timeline entries (`message` / `turn` / `event`) — the
|
|
3
|
+
* common core of the host element's `buildDiagnosticEntries` and any HEADLESS consumer that drives
|
|
4
|
+
* a `ChatDriver` of its own (e.g. `ai-service` running the engine server-side) and needs to harvest
|
|
5
|
+
* that engine's debug log for the client to fold into its own download / persisted stream
|
|
6
|
+
* (GENC-1461 unified diagnostics). DOM-clean and dependency-light so it works in bare Node.
|
|
7
|
+
*
|
|
8
|
+
* It deliberately excludes the `meta-snapshot` entry: that block is host/DOM-specific (it reads
|
|
9
|
+
* `window.location.host`, the element's agent list, live context/cost), and a merged log keeps only
|
|
10
|
+
* the newest snapshot anyway — a headless source's agent/prompt/tools are already captured in its
|
|
11
|
+
* `turn` entries.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ChatMessage } from '@genesislcap/foundation-ai';
|
|
17
|
+
import { flattenSubAgentMessages } from '../../utils/flatten-sub-agent-messages';
|
|
18
|
+
import type { MetaEvent } from '../debug-event-log';
|
|
19
|
+
import type { DiagnosticEntry } from './diagnostics';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The minimal shape this builder reads off a turn snapshot. The driver's full `TurnSnapshot` is a
|
|
23
|
+
* structural superset, so it assigns to this without the builder depending on the component layer.
|
|
24
|
+
*/
|
|
25
|
+
export interface TurnSnapshotLike {
|
|
26
|
+
turnIndex: string;
|
|
27
|
+
systemPrompt?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the `message` / `turn` / `event` timeline entries from a driver's pull surfaces
|
|
32
|
+
* (`getRawHistory()` / `getTurnSnapshots()` / `getMetaEvents(sessionKey)`). Order within the array
|
|
33
|
+
* is message → turn → event; the caller (`assembleDebugLog`) sorts the whole timeline by timestamp,
|
|
34
|
+
* so this ordering only sets the co-timestamp tie-break. Pure.
|
|
35
|
+
*/
|
|
36
|
+
export function buildTimelineEntries(input: {
|
|
37
|
+
turnSnapshots: readonly TurnSnapshotLike[];
|
|
38
|
+
messages: readonly ChatMessage[];
|
|
39
|
+
metaEvents: readonly MetaEvent[];
|
|
40
|
+
}): DiagnosticEntry[] {
|
|
41
|
+
const { turnSnapshots, messages, metaEvents } = input;
|
|
42
|
+
|
|
43
|
+
// Collapse a turn's systemPrompt when byte-identical to the previous turn's — a stable agent
|
|
44
|
+
// repeats the same (often multi-KB) prompt every turn, while a stateful agent's changes. The
|
|
45
|
+
// prompt is shown in full whenever it changes, so prompt evolution stays visible.
|
|
46
|
+
let lastFullPrompt: string | undefined;
|
|
47
|
+
let lastFullIndex = '';
|
|
48
|
+
const turns = turnSnapshots.map((t) => {
|
|
49
|
+
let { systemPrompt } = t;
|
|
50
|
+
if (systemPrompt != null && systemPrompt === lastFullPrompt) {
|
|
51
|
+
systemPrompt = `<repeated — identical to turn ${lastFullIndex}>`;
|
|
52
|
+
} else if (systemPrompt != null) {
|
|
53
|
+
lastFullPrompt = systemPrompt;
|
|
54
|
+
lastFullIndex = t.turnIndex;
|
|
55
|
+
}
|
|
56
|
+
return { kind: 'turn' as const, ...t, systemPrompt };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Sub-agent conversations are stored nested on the parent tool call's `subAgentTrace`;
|
|
60
|
+
// `flattenSubAgentMessages` hoists them to top-level `kind: 'message'` entries (breadcrumbed +
|
|
61
|
+
// correlated, the nested copy moved out — not duplicated) so the timeline reads chronologically.
|
|
62
|
+
const messageEntries = flattenSubAgentMessages(messages);
|
|
63
|
+
const eventEntries = metaEvents.map((e) => ({ kind: 'event' as const, ...e }));
|
|
64
|
+
|
|
65
|
+
return [...messageEntries, ...turns, ...eventEntries] as DiagnosticEntry[];
|
|
66
|
+
}
|