@genesislcap/ai-assistant 14.467.2 → 14.468.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.d.ts +39 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +38 -0
- package/dist/dts/components/chat-driver/chat-driver.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/utils/condense-history.d.ts +115 -0
- package/dist/dts/utils/condense-history.d.ts.map +1 -0
- package/dist/dts/utils/condense-history.test.d.ts +2 -0
- package/dist/dts/utils/condense-history.test.d.ts.map +1 -0
- package/dist/esm/components/chat-driver/chat-driver.js +108 -7
- package/dist/esm/components/chat-driver/chat-driver.test.js +196 -1
- package/dist/esm/state/debug-event-log.js +3 -2
- package/dist/esm/utils/condense-history.js +218 -0
- package/dist/esm/utils/condense-history.test.js +297 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -16
- package/src/components/chat-driver/chat-driver.test.ts +233 -1
- package/src/components/chat-driver/chat-driver.ts +120 -6
- package/src/state/debug-event-log.ts +4 -2
- package/src/utils/condense-history.test.ts +373 -0
- package/src/utils/condense-history.ts +306 -0
|
@@ -14,15 +14,20 @@ const scriptedProvider = (responses) => {
|
|
|
14
14
|
const advertisedPerCall = [];
|
|
15
15
|
const toolChoicePerCall = [];
|
|
16
16
|
const temperaturePerCall = [];
|
|
17
|
+
const historyPerCall = [];
|
|
17
18
|
return {
|
|
18
19
|
advertisedPerCall,
|
|
19
20
|
toolChoicePerCall,
|
|
20
21
|
temperaturePerCall,
|
|
21
|
-
|
|
22
|
+
historyPerCall,
|
|
23
|
+
chat: (history, _userMessage, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
22
24
|
var _a, _b;
|
|
23
25
|
advertisedPerCall.push(((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name));
|
|
24
26
|
toolChoicePerCall.push(options === null || options === void 0 ? void 0 : options.toolChoice);
|
|
25
27
|
temperaturePerCall.push(options === null || options === void 0 ? void 0 : options.temperature);
|
|
28
|
+
// Snapshot the slice (condensation produces fresh objects per call and never
|
|
29
|
+
// mutates stored history, so a shallow array copy is a stable per-call view).
|
|
30
|
+
historyPerCall.push([...history]);
|
|
26
31
|
// Once the script is exhausted, end the turn with a plain text reply.
|
|
27
32
|
return (_b = queue.shift()) !== null && _b !== void 0 ? _b : { role: 'assistant', content: 'done' };
|
|
28
33
|
}),
|
|
@@ -1192,3 +1197,193 @@ modelAttr('omits the model key entirely when the provider reports no status', ()
|
|
|
1192
1197
|
driver.dispose();
|
|
1193
1198
|
}));
|
|
1194
1199
|
modelAttr.run();
|
|
1200
|
+
// ---------------------------------------------------------------------------
|
|
1201
|
+
// condenseWhen — tool-declared context condensation (wiring through the loop)
|
|
1202
|
+
// ---------------------------------------------------------------------------
|
|
1203
|
+
const condense = createLogicSuite('ChatDriver condenseWhen');
|
|
1204
|
+
condense.after(() => {
|
|
1205
|
+
agenticActivityBus.close();
|
|
1206
|
+
});
|
|
1207
|
+
const bigBody = 'A'.repeat(2000);
|
|
1208
|
+
/** An assistant turn calling `read` with a path arg (so `by: args.path` resolves). */
|
|
1209
|
+
const readsPath = (id, path) => ({
|
|
1210
|
+
role: 'assistant',
|
|
1211
|
+
content: '',
|
|
1212
|
+
toolCalls: [{ id, name: 'read', args: { path } }],
|
|
1213
|
+
});
|
|
1214
|
+
condense('collapses a superseded result in the model slice but keeps stored history full', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1215
|
+
var _a, _b;
|
|
1216
|
+
const sessionKey = 'condense-supersede';
|
|
1217
|
+
clearMetaEventRegistry();
|
|
1218
|
+
const provider = scriptedProvider([
|
|
1219
|
+
readsPath('r1', 'A'),
|
|
1220
|
+
readsPath('r2', 'A'), // re-read of the same path supersedes r1
|
|
1221
|
+
]);
|
|
1222
|
+
const config = agent({
|
|
1223
|
+
name: 'reader',
|
|
1224
|
+
systemPrompt: 'read files',
|
|
1225
|
+
toolDefinitions: [def('read')],
|
|
1226
|
+
toolHandlers: {
|
|
1227
|
+
read: (args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1228
|
+
ctx.condenseWhen({
|
|
1229
|
+
on: { kind: 'superseded', by: String(args.path) },
|
|
1230
|
+
response: 'pointer',
|
|
1231
|
+
});
|
|
1232
|
+
return bigBody;
|
|
1233
|
+
}),
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
const driver = makeDriver(config, provider, sessionKey);
|
|
1237
|
+
const result = yield driver.sendMessage('go');
|
|
1238
|
+
assert.is(result.reason, 'done');
|
|
1239
|
+
// The 3rd provider call (index 2) ran after both reads: r1 is superseded, r2 is latest.
|
|
1240
|
+
const slice = provider.historyPerCall[2];
|
|
1241
|
+
const r1 = slice.find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'r1'; });
|
|
1242
|
+
const r2 = slice.find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'r2'; });
|
|
1243
|
+
assert.match(r1.toolResult.content, /re-call to restore/, 'earlier read collapsed for the model');
|
|
1244
|
+
assert.is(r2.toolResult.content, bigBody, 'latest read still full');
|
|
1245
|
+
// Stored history is untouched — both reads remain full for the UI / debug export.
|
|
1246
|
+
assert.is(toolResultContents(driver).filter((c) => c === bigBody).length, 2, 'stored history retains both full reads');
|
|
1247
|
+
// One context.condensed meta-event, for r1's response, recorded once.
|
|
1248
|
+
const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
|
|
1249
|
+
assert.is(events.length, 1, 'the full→stub transition is reported exactly once');
|
|
1250
|
+
assert.is((_a = events[0].detail) === null || _a === void 0 ? void 0 : _a.toolCallId, 'r1');
|
|
1251
|
+
assert.is((_b = events[0].detail) === null || _b === void 0 ? void 0 : _b.target, 'response');
|
|
1252
|
+
}));
|
|
1253
|
+
condense('first-wins: a second condenseWhen for the same tool call is ignored', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1254
|
+
const sessionKey = 'condense-first-wins';
|
|
1255
|
+
clearMetaEventRegistry();
|
|
1256
|
+
const provider = scriptedProvider([readsPath('r1', 'A'), readsPath('r2', 'A')]);
|
|
1257
|
+
const config = agent({
|
|
1258
|
+
name: 'reader',
|
|
1259
|
+
systemPrompt: 'read files',
|
|
1260
|
+
toolDefinitions: [def('read')],
|
|
1261
|
+
toolHandlers: {
|
|
1262
|
+
read: (args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1263
|
+
// First declaration wins; the second (a different mode) is dropped.
|
|
1264
|
+
ctx.condenseWhen({
|
|
1265
|
+
on: { kind: 'superseded', by: String(args.path) },
|
|
1266
|
+
response: 'pointer',
|
|
1267
|
+
});
|
|
1268
|
+
ctx.condenseWhen({
|
|
1269
|
+
on: { kind: 'superseded', by: String(args.path) },
|
|
1270
|
+
response: { replaceWith: 'SECOND' },
|
|
1271
|
+
});
|
|
1272
|
+
return bigBody;
|
|
1273
|
+
}),
|
|
1274
|
+
},
|
|
1275
|
+
});
|
|
1276
|
+
const driver = makeDriver(config, provider, sessionKey);
|
|
1277
|
+
yield driver.sendMessage('go');
|
|
1278
|
+
const collapsed = provider.historyPerCall[2].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'r1'; });
|
|
1279
|
+
assert.match(collapsed.toolResult.content, /re-call to restore/, 'the first (pointer) policy applied');
|
|
1280
|
+
assert.is.not(collapsed.toolResult.content, 'SECOND', 'the later policy was ignored');
|
|
1281
|
+
}));
|
|
1282
|
+
condense('age clock is monotonic across turns — collapses on a later, separate turn', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1283
|
+
var _a;
|
|
1284
|
+
// Regression guard for the per-turn-reset bug: the age clock is the local loop
|
|
1285
|
+
// counter that resets every `sendMessage`, so an `age` payload from turn 1
|
|
1286
|
+
// would never collapse across short turns. The driver now uses a monotonic
|
|
1287
|
+
// model-call counter, so it does.
|
|
1288
|
+
const sessionKey = 'condense-age-cross-turn';
|
|
1289
|
+
clearMetaEventRegistry();
|
|
1290
|
+
const provider = scriptedProvider([
|
|
1291
|
+
{ role: 'assistant', content: '', toolCalls: [{ id: 'n1', name: 'notes', args: {} }] }, // turn 1, call 1
|
|
1292
|
+
{ role: 'assistant', content: 'noted' }, // turn 1, call 2 — model sees n1 once (full), turn ends
|
|
1293
|
+
{ role: 'assistant', content: 'ok' }, // turn 2, call 1 — a short follow-up, no tools
|
|
1294
|
+
]);
|
|
1295
|
+
const config = agent({
|
|
1296
|
+
name: 'note-taker',
|
|
1297
|
+
systemPrompt: 'take notes',
|
|
1298
|
+
toolDefinitions: [def('notes')],
|
|
1299
|
+
toolHandlers: {
|
|
1300
|
+
notes: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1301
|
+
ctx.condenseWhen({ on: { kind: 'age', turns: 1 }, response: 'pointer' });
|
|
1302
|
+
return bigBody;
|
|
1303
|
+
}),
|
|
1304
|
+
},
|
|
1305
|
+
});
|
|
1306
|
+
const driver = makeDriver(config, provider, sessionKey);
|
|
1307
|
+
yield driver.sendMessage('first'); // turn 1
|
|
1308
|
+
yield driver.sendMessage('second'); // turn 2 — a separate, short sendMessage
|
|
1309
|
+
// Turn 1's consume call (provider call #1) saw n1 in full — its one allowed look.
|
|
1310
|
+
const turn1Consume = provider.historyPerCall[1].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'n1'; });
|
|
1311
|
+
assert.is(turn1Consume.toolResult.content, bigBody, 'full on its one allowed turn');
|
|
1312
|
+
// Turn 2's first call (#2) is a SEPARATE sendMessage — the local loop counter has
|
|
1313
|
+
// reset to 0, but the monotonic clock kept counting, so n1 is now collapsed.
|
|
1314
|
+
// Under the old per-turn counter it would still be full here.
|
|
1315
|
+
const turn2 = provider.historyPerCall[2].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'n1'; });
|
|
1316
|
+
assert.match(turn2.toolResult.content, /re-call to restore/, 'collapsed on the next turn');
|
|
1317
|
+
assert.ok(toolResultContents(driver).includes(bigBody), 'stored history retains the full note');
|
|
1318
|
+
const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
|
|
1319
|
+
assert.is(events.length, 1);
|
|
1320
|
+
assert.is((_a = events[0].detail) === null || _a === void 0 ? void 0 : _a.trigger, 'age:1');
|
|
1321
|
+
}));
|
|
1322
|
+
condense('turnEnd: full during its request, collapsed on the next user turn', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1323
|
+
var _a;
|
|
1324
|
+
const sessionKey = 'condense-turn-end';
|
|
1325
|
+
clearMetaEventRegistry();
|
|
1326
|
+
const provider = scriptedProvider([
|
|
1327
|
+
{ role: 'assistant', content: '', toolCalls: [{ id: 'n1', name: 'notes', args: {} }] }, // turn 1, call 1
|
|
1328
|
+
{ role: 'assistant', content: 'noted' }, // turn 1, call 2 — n1 still full this turn
|
|
1329
|
+
{ role: 'assistant', content: 'ok' }, // turn 2 — a separate request
|
|
1330
|
+
]);
|
|
1331
|
+
const config = agent({
|
|
1332
|
+
name: 'note-taker',
|
|
1333
|
+
systemPrompt: 'take notes',
|
|
1334
|
+
toolDefinitions: [def('notes')],
|
|
1335
|
+
toolHandlers: {
|
|
1336
|
+
notes: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1337
|
+
ctx.condenseWhen({ on: { kind: 'turnEnd' }, response: 'pointer' });
|
|
1338
|
+
return bigBody;
|
|
1339
|
+
}),
|
|
1340
|
+
},
|
|
1341
|
+
});
|
|
1342
|
+
const driver = makeDriver(config, provider, sessionKey);
|
|
1343
|
+
yield driver.sendMessage('first'); // turn 1
|
|
1344
|
+
yield driver.sendMessage('second'); // turn 2
|
|
1345
|
+
const inTurn1 = provider.historyPerCall[1].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'n1'; });
|
|
1346
|
+
assert.is(inTurn1.toolResult.content, bigBody, 'full for the rest of the request it served');
|
|
1347
|
+
const inTurn2 = provider.historyPerCall[2].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 'n1'; });
|
|
1348
|
+
assert.match(inTurn2.toolResult.content, /re-call to restore/, 'collapsed once the turn ended');
|
|
1349
|
+
const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
|
|
1350
|
+
assert.is(events.length, 1);
|
|
1351
|
+
assert.is((_a = events[0].detail) === null || _a === void 0 ? void 0 : _a.trigger, 'turnEnd');
|
|
1352
|
+
}));
|
|
1353
|
+
condense('agentEnd: collapses once the agent releases (flow complete)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1354
|
+
var _a;
|
|
1355
|
+
const sessionKey = 'condense-agent-end';
|
|
1356
|
+
clearMetaEventRegistry();
|
|
1357
|
+
const provider = scriptedProvider([
|
|
1358
|
+
{ role: 'assistant', content: '', toolCalls: [{ id: 's1', name: 'load', args: {} }] }, // call 1
|
|
1359
|
+
{ role: 'assistant', content: '', toolCalls: [{ id: 'f1', name: 'finish', args: {} }] }, // call 2 — releases
|
|
1360
|
+
{ role: 'assistant', content: 'wrapped up' }, // call 3 — flow done
|
|
1361
|
+
]);
|
|
1362
|
+
const config = agent({
|
|
1363
|
+
name: 'wizard',
|
|
1364
|
+
systemPrompt: 'a flow',
|
|
1365
|
+
toolDefinitions: [def('load'), def('finish')],
|
|
1366
|
+
toolHandlers: {
|
|
1367
|
+
load: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1368
|
+
ctx.condenseWhen({ on: { kind: 'agentEnd' }, response: 'pointer' });
|
|
1369
|
+
return bigBody;
|
|
1370
|
+
}),
|
|
1371
|
+
finish: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
1372
|
+
ctx.releaseAgent();
|
|
1373
|
+
return 'flow complete';
|
|
1374
|
+
}),
|
|
1375
|
+
},
|
|
1376
|
+
});
|
|
1377
|
+
const driver = makeDriver(config, provider, sessionKey);
|
|
1378
|
+
yield driver.sendMessage('run the flow');
|
|
1379
|
+
// call #2 (index 1) ran before `finish` released — schema still full.
|
|
1380
|
+
const beforeRelease = provider.historyPerCall[1].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 's1'; });
|
|
1381
|
+
assert.is(beforeRelease.toolResult.content, bigBody, 'full while the flow is active');
|
|
1382
|
+
// call #3 (index 2) ran after release — schema collapsed.
|
|
1383
|
+
const afterRelease = provider.historyPerCall[2].find((m) => { var _a; return ((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.toolCallId) === 's1'; });
|
|
1384
|
+
assert.match(afterRelease.toolResult.content, /re-call to restore/, 'collapsed once released');
|
|
1385
|
+
const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
|
|
1386
|
+
assert.is(events.length, 1);
|
|
1387
|
+
assert.is((_a = events[0].detail) === null || _a === void 0 ? void 0 : _a.trigger, 'agentEnd');
|
|
1388
|
+
}));
|
|
1389
|
+
condense.run();
|
|
@@ -61,6 +61,7 @@ export const META_EVENT_IMPORTANCE = {
|
|
|
61
61
|
'driver.wired': 'low',
|
|
62
62
|
'driver.unwired': 'low',
|
|
63
63
|
'context.updated': 'low',
|
|
64
|
+
'context.condensed': 'low',
|
|
64
65
|
'panel.toggled': 'low',
|
|
65
66
|
'attachment.added': 'low',
|
|
66
67
|
};
|
|
@@ -188,8 +189,8 @@ export const DEBUG_LOG_README = [
|
|
|
188
189
|
"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.",
|
|
189
190
|
"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.",
|
|
190
191
|
"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'.",
|
|
191
|
-
"Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
|
|
192
|
-
'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (model/provider for the upcoming turns), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
|
|
192
|
+
"Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
|
|
193
|
+
'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (model/provider for the upcoming turns), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
|
|
193
194
|
'Sub-agent meta events: a sub-agent\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `"<parent> › <sub-agent>"` breadcrumb that composes when nested (e.g. `"UI Builder › Planner › Grounding"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\'s span.',
|
|
194
195
|
"`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, token usage, session cost), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.",
|
|
195
196
|
'To debug a failure: find the last turn.error or tool.failed, then read upward for the user message, the turn(s), and the agent/provider/state events that led into it.',
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-context condensation: collapse stale tool payloads out of the history
|
|
3
|
+
* sent to the model, while leaving stored history untouched.
|
|
4
|
+
*
|
|
5
|
+
* The driver owns the registry (tool handlers populate it via `condenseWhen`)
|
|
6
|
+
* and the cadence (this runs before every provider call). This module owns the
|
|
7
|
+
* pure application: given the registry + the current loop iteration, produce a
|
|
8
|
+
* rewritten copy of the history with stale args/results replaced by stubs. Kept
|
|
9
|
+
* pure (no driver/session state, no logging) so it unit-tests in isolation —
|
|
10
|
+
* the one-time `context.condensed` signal is delivered via an `onCondensed`
|
|
11
|
+
* callback rather than reaching into the debug-event log directly.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Below this serialized size a tool payload isn't worth condensing — the stub
|
|
17
|
+
* would save little and cost a breadcrumb. A driver-level floor so the public
|
|
18
|
+
* `condenseWhen` API needs no per-policy threshold field.
|
|
19
|
+
*/
|
|
20
|
+
export const CONDENSE_MIN_CHARS = 1000;
|
|
21
|
+
/** Key the collapsed args are stored under — a tool-call's args must stay a Record. */
|
|
22
|
+
export const CONDENSED_ARGS_KEY = 'condensed';
|
|
23
|
+
/**
|
|
24
|
+
* Rough chars-per-token divisor for the `tokensSaved` estimate. No tokenizer is
|
|
25
|
+
* available in this stack; ~4 chars/token is the usual English approximation.
|
|
26
|
+
*/
|
|
27
|
+
const APPROX_CHARS_PER_TOKEN = 4;
|
|
28
|
+
/** Short human-readable reason for the stub text, per fired trigger. */
|
|
29
|
+
function triggerReason(trigger) {
|
|
30
|
+
switch (trigger.kind) {
|
|
31
|
+
case 'age':
|
|
32
|
+
return 'aged out';
|
|
33
|
+
case 'turnEnd':
|
|
34
|
+
return 'turn ended';
|
|
35
|
+
case 'agentEnd':
|
|
36
|
+
return 'agent finished';
|
|
37
|
+
default:
|
|
38
|
+
return 'superseded';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Framework-generated `pointer` stub — advertises that re-calling restores the content. */
|
|
42
|
+
function condenseStub(target, tool, trigger, origLen) {
|
|
43
|
+
const what = target === 'args' ? 'args' : 'result';
|
|
44
|
+
const key = trigger.kind === 'superseded' ? ` ${trigger.by}` : '';
|
|
45
|
+
return `[${tool}${key} — ${what} elided, ~${origLen} chars (${triggerReason(trigger)}); re-call to restore]`;
|
|
46
|
+
}
|
|
47
|
+
/** Stable label for the `context.condensed` meta-event's `trigger` field. */
|
|
48
|
+
function triggerLabel(trigger) {
|
|
49
|
+
switch (trigger.kind) {
|
|
50
|
+
case 'age':
|
|
51
|
+
return `age:${trigger.turns}`;
|
|
52
|
+
case 'superseded':
|
|
53
|
+
return `superseded:${trigger.by}`;
|
|
54
|
+
default:
|
|
55
|
+
return trigger.kind; // 'turnEnd' | 'agentEnd'
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function estimateTokensSaved(origLen, stubLen) {
|
|
59
|
+
return Math.max(0, Math.ceil((origLen - stubLen) / APPROX_CHARS_PER_TOKEN));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Collapse stale tool payloads (declared via `condenseWhen`) out of the
|
|
63
|
+
* model-bound history. Pure over the `history` array — it emits new message
|
|
64
|
+
* objects and never mutates the input messages (mirroring the agent-masking
|
|
65
|
+
* transform). It DOES, however, flip the `reported*` flag on the matching
|
|
66
|
+
* `policies` entry the first time a payload collapses — the report-once gate that
|
|
67
|
+
* keeps `onCondensed` firing once per (tool call, payload) even though the
|
|
68
|
+
* collapse itself re-runs before every provider call.
|
|
69
|
+
*
|
|
70
|
+
* Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
|
|
71
|
+
* collapses the payload; all are monotonic, so the collapse never reverts):
|
|
72
|
+
* - `superseded` — among all registered calls sharing a `by` key, the LAST in
|
|
73
|
+
* history order survives; every earlier one's targeted payload collapses. A
|
|
74
|
+
* re-call with the same key becomes the new survivor and re-arms the rest.
|
|
75
|
+
* - `age` — the result is first visible one model-call after the call, so `turns`
|
|
76
|
+
* is how many model-calls may see the full result before it collapses
|
|
77
|
+
* (fires when `modelCall − callModelCall > turns`): `turns: 1` is seen once,
|
|
78
|
+
* then collapses. The clock is monotonic across turns.
|
|
79
|
+
* - `turnEnd` — collapses once the turn (`sendMessage`) that made the call has
|
|
80
|
+
* ended (fires when `turn > callTurn`): full for the rest of that request,
|
|
81
|
+
* gone on every later one.
|
|
82
|
+
* - `agentEnd` — collapses once the agent activation that made the call has ended
|
|
83
|
+
* (a swap to another agent, or `releaseAgent`/`completeSubAgent`): kept across
|
|
84
|
+
* all of the agent's turns, dropped when its flow finishes.
|
|
85
|
+
*
|
|
86
|
+
* The tool-call/result envelope is never removed (providers reject orphaned
|
|
87
|
+
* calls/results) — only the args object or the result content is replaced.
|
|
88
|
+
*
|
|
89
|
+
* @param history - The to-model history copy to rewrite (not mutated).
|
|
90
|
+
* @param policies - The driver's registry, keyed by tool-call id. Its entries'
|
|
91
|
+
* `reported*` flags are flipped as payloads first collapse.
|
|
92
|
+
* @param ctx - The driver's live clocks (model-call / turn / agent-activation).
|
|
93
|
+
* @param onCondensed - Invoked once per payload at its full→stub transition.
|
|
94
|
+
*/
|
|
95
|
+
export function applyCondensation(history, policies, ctx, onCondensed) {
|
|
96
|
+
var _a;
|
|
97
|
+
if (policies.size === 0)
|
|
98
|
+
return history;
|
|
99
|
+
const triggersOf = (entry) => Array.isArray(entry.policy.on) ? entry.policy.on : [entry.policy.on];
|
|
100
|
+
// One pass: resolve supersession survivors and a toolCallId → tool-name
|
|
101
|
+
// lookup. For each `superseded` key the LAST call in history order survives.
|
|
102
|
+
// The name lookup lets a tool message (which carries only an id) name its tool
|
|
103
|
+
// in stubs and events.
|
|
104
|
+
const latestByKey = new Map();
|
|
105
|
+
const nameById = new Map();
|
|
106
|
+
for (const msg of history) {
|
|
107
|
+
if (!((_a = msg.toolCalls) === null || _a === void 0 ? void 0 : _a.length))
|
|
108
|
+
continue;
|
|
109
|
+
for (const tc of msg.toolCalls) {
|
|
110
|
+
nameById.set(tc.id, tc.name);
|
|
111
|
+
const entry = policies.get(tc.id);
|
|
112
|
+
if (!entry)
|
|
113
|
+
continue;
|
|
114
|
+
for (const t of triggersOf(entry)) {
|
|
115
|
+
if (t.kind === 'superseded')
|
|
116
|
+
latestByKey.set(t.by, tc.id);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const triggerFires = (toolCallId, entry, trig) => {
|
|
121
|
+
switch (trig.kind) {
|
|
122
|
+
// Result first visible one model-call after the call, so collapse one call
|
|
123
|
+
// past the last allowed view: turns:1 → seen once, then gone. Monotonic
|
|
124
|
+
// clock, so this holds across turn boundaries too.
|
|
125
|
+
case 'age':
|
|
126
|
+
return ctx.modelCall - entry.iteration > trig.turns;
|
|
127
|
+
case 'turnEnd':
|
|
128
|
+
return ctx.turn > entry.turn;
|
|
129
|
+
case 'agentEnd':
|
|
130
|
+
return ctx.activationEnded(entry.activation);
|
|
131
|
+
default:
|
|
132
|
+
return latestByKey.get(trig.by) !== toolCallId; // superseded by a newer call
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
// The first listed trigger that fires — drives the collapse, the stub reason,
|
|
136
|
+
// and the event label. `undefined` means the payload stays full.
|
|
137
|
+
const firstFired = (toolCallId, entry) => triggersOf(entry).find((t) => triggerFires(toolCallId, entry, t));
|
|
138
|
+
return history.map((msg) => {
|
|
139
|
+
var _a, _b, _c, _d;
|
|
140
|
+
// Tool-call ARGS live on the assistant message.
|
|
141
|
+
if ((_a = msg.toolCalls) === null || _a === void 0 ? void 0 : _a.length) {
|
|
142
|
+
let changed = false;
|
|
143
|
+
const toolCalls = msg.toolCalls.map((tc) => {
|
|
144
|
+
var _a;
|
|
145
|
+
const entry = policies.get(tc.id);
|
|
146
|
+
if (!(entry === null || entry === void 0 ? void 0 : entry.policy.args))
|
|
147
|
+
return tc;
|
|
148
|
+
const fired = firstFired(tc.id, entry);
|
|
149
|
+
if (!fired)
|
|
150
|
+
return tc;
|
|
151
|
+
const origLen = JSON.stringify((_a = tc.args) !== null && _a !== void 0 ? _a : {}).length;
|
|
152
|
+
if (origLen < CONDENSE_MIN_CHARS)
|
|
153
|
+
return tc;
|
|
154
|
+
changed = true;
|
|
155
|
+
const result = entry.policy.args;
|
|
156
|
+
// Args must stay a Record; `drop` is an empty object, otherwise stash the
|
|
157
|
+
// stub under a single key. Spread `tc` so providerMetadata (e.g. the
|
|
158
|
+
// Gemini reasoning signature that must round-trip) and UI fields survive.
|
|
159
|
+
const args = result === 'drop'
|
|
160
|
+
? {}
|
|
161
|
+
: {
|
|
162
|
+
[CONDENSED_ARGS_KEY]: result === 'pointer'
|
|
163
|
+
? condenseStub('args', tc.name, fired, origLen)
|
|
164
|
+
: result.replaceWith,
|
|
165
|
+
};
|
|
166
|
+
if (!entry.reportedArgs) {
|
|
167
|
+
entry.reportedArgs = true;
|
|
168
|
+
const stubLen = JSON.stringify(args).length;
|
|
169
|
+
onCondensed({
|
|
170
|
+
turn: entry.iteration,
|
|
171
|
+
toolCallId: tc.id,
|
|
172
|
+
tool: tc.name,
|
|
173
|
+
target: 'args',
|
|
174
|
+
trigger: triggerLabel(fired),
|
|
175
|
+
stubLen,
|
|
176
|
+
tokensSaved: estimateTokensSaved(origLen, stubLen),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return Object.assign(Object.assign({}, tc), { args });
|
|
180
|
+
});
|
|
181
|
+
return changed ? Object.assign(Object.assign({}, msg), { toolCalls }) : msg;
|
|
182
|
+
}
|
|
183
|
+
// The tool RESULT lives on the tool message.
|
|
184
|
+
if (msg.toolResult) {
|
|
185
|
+
const entry = policies.get(msg.toolResult.toolCallId);
|
|
186
|
+
// Defensive `?? 0` for restored/malformed history — `content` is typed
|
|
187
|
+
// `string`, but a 0-length payload is simply below the floor and skipped.
|
|
188
|
+
const origLen = (_c = (_b = msg.toolResult.content) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0;
|
|
189
|
+
const fired = (entry === null || entry === void 0 ? void 0 : entry.policy.response)
|
|
190
|
+
? firstFired(msg.toolResult.toolCallId, entry)
|
|
191
|
+
: undefined;
|
|
192
|
+
if (entry && fired && origLen >= CONDENSE_MIN_CHARS) {
|
|
193
|
+
const tool = (_d = nameById.get(msg.toolResult.toolCallId)) !== null && _d !== void 0 ? _d : msg.toolResult.toolCallId;
|
|
194
|
+
const result = entry.policy.response; // `fired` is only set when response is declared
|
|
195
|
+
// Kept non-empty — Anthropic rejects empty tool_result content.
|
|
196
|
+
const content = result === 'drop'
|
|
197
|
+
? '[elided]'
|
|
198
|
+
: result === 'pointer'
|
|
199
|
+
? condenseStub('response', tool, fired, origLen)
|
|
200
|
+
: result.replaceWith;
|
|
201
|
+
if (!entry.reportedResponse) {
|
|
202
|
+
entry.reportedResponse = true;
|
|
203
|
+
onCondensed({
|
|
204
|
+
turn: entry.iteration,
|
|
205
|
+
toolCallId: msg.toolResult.toolCallId,
|
|
206
|
+
tool,
|
|
207
|
+
target: 'response',
|
|
208
|
+
trigger: triggerLabel(fired),
|
|
209
|
+
stubLen: content.length,
|
|
210
|
+
tokensSaved: estimateTokensSaved(origLen, content.length),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return Object.assign(Object.assign({}, msg), { toolResult: Object.assign(Object.assign({}, msg.toolResult), { content }) });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return msg;
|
|
217
|
+
});
|
|
218
|
+
}
|