@genesislcap/ai-assistant 14.469.0 → 14.470.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 +272 -3
- package/dist/ai-assistant.d.ts +95 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +34 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
- package/dist/dts/config/config.d.ts +42 -2
- package/dist/dts/config/config.d.ts.map +1 -1
- package/dist/dts/config/define-stateful-agent.d.ts +14 -1
- package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +3 -0
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts +3 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/utils/condense-history.d.ts +10 -0
- package/dist/dts/utils/condense-history.d.ts.map +1 -1
- package/dist/dts/utils/strip-agent-handlers.d.ts +2 -1
- package/dist/dts/utils/strip-agent-handlers.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +87 -13
- package/dist/esm/components/chat-driver/chat-driver.test.js +95 -1
- package/dist/esm/config/define-stateful-agent.js +18 -0
- package/dist/esm/state/debug-event-log.js +1 -1
- package/dist/esm/utils/condense-history.js +11 -1
- package/dist/esm/utils/condense-history.test.js +43 -2
- package/dist/esm/utils/strip-agent-handlers.js +2 -1
- package/package.json +16 -16
- package/src/components/chat-driver/chat-driver.test.ts +115 -1
- package/src/components/chat-driver/chat-driver.ts +121 -18
- package/src/config/config.ts +46 -1
- package/src/config/define-stateful-agent.ts +47 -0
- package/src/state/debug-event-log.ts +5 -2
- package/src/utils/condense-history.test.ts +65 -2
- package/src/utils/condense-history.ts +18 -1
- package/src/utils/strip-agent-handlers.ts +2 -1
|
@@ -34,6 +34,8 @@ function triggerReason(trigger) {
|
|
|
34
34
|
return 'turn ended';
|
|
35
35
|
case 'agentEnd':
|
|
36
36
|
return 'agent finished';
|
|
37
|
+
case 'phaseEnd':
|
|
38
|
+
return 'phase ended';
|
|
37
39
|
default:
|
|
38
40
|
return 'superseded';
|
|
39
41
|
}
|
|
@@ -52,7 +54,7 @@ function triggerLabel(trigger) {
|
|
|
52
54
|
case 'superseded':
|
|
53
55
|
return `superseded:${trigger.by}`;
|
|
54
56
|
default:
|
|
55
|
-
return trigger.kind; // 'turnEnd' | 'agentEnd'
|
|
57
|
+
return trigger.kind; // 'turnEnd' | 'agentEnd' | 'phaseEnd'
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
function estimateTokensSaved(origLen, stubLen) {
|
|
@@ -82,6 +84,9 @@ function estimateTokensSaved(origLen, stubLen) {
|
|
|
82
84
|
* - `agentEnd` — collapses once the agent activation that made the call has ended
|
|
83
85
|
* (a swap to another agent, or `releaseAgent`/`completeSubAgent`): kept across
|
|
84
86
|
* all of the agent's turns, dropped when its flow finishes.
|
|
87
|
+
* - `phaseEnd` — collapses once the app advances its phase epoch via `endPhase()`
|
|
88
|
+
* after the call (a sub-`agentEnd` boundary the author declares), OR at
|
|
89
|
+
* `agentEnd` as a backstop if no boundary is ever declared.
|
|
85
90
|
*
|
|
86
91
|
* The tool-call/result envelope is never removed (providers reject orphaned
|
|
87
92
|
* calls/results) — only the args object or the result content is replaced.
|
|
@@ -128,6 +133,11 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
|
|
|
128
133
|
return ctx.turn > entry.turn;
|
|
129
134
|
case 'agentEnd':
|
|
130
135
|
return ctx.activationEnded(entry.activation);
|
|
136
|
+
// Fires when the app advanced the phase epoch since the call — OR, as a
|
|
137
|
+
// backstop, when the whole activation has ended, so a phase boundary that
|
|
138
|
+
// is never declared still collapses at `agentEnd` rather than never.
|
|
139
|
+
case 'phaseEnd':
|
|
140
|
+
return ctx.phaseEnded(entry.phaseEpoch) || ctx.activationEnded(entry.activation);
|
|
131
141
|
default:
|
|
132
142
|
return latestByKey.get(trig.by) !== toolCallId; // superseded by a newer call
|
|
133
143
|
}
|
|
@@ -10,21 +10,23 @@ const toolMsg = (toolCallId, content) => ({
|
|
|
10
10
|
toolResult: { toolCallId, content },
|
|
11
11
|
});
|
|
12
12
|
const reg = (policy, clocks = {}) => {
|
|
13
|
-
var _a, _b, _c;
|
|
13
|
+
var _a, _b, _c, _d;
|
|
14
14
|
return ({
|
|
15
15
|
policy,
|
|
16
16
|
iteration: (_a = clocks.iteration) !== null && _a !== void 0 ? _a : 1,
|
|
17
17
|
turn: (_b = clocks.turn) !== null && _b !== void 0 ? _b : 1,
|
|
18
18
|
activation: (_c = clocks.activation) !== null && _c !== void 0 ? _c : 1,
|
|
19
|
+
phaseEpoch: (_d = clocks.phaseEpoch) !== null && _d !== void 0 ? _d : 1,
|
|
19
20
|
});
|
|
20
21
|
};
|
|
21
22
|
/** A CondenseContext for the pure transform. Defaults fire nothing; override per test. */
|
|
22
23
|
const ctx = (o = {}) => {
|
|
23
|
-
var _a, _b, _c;
|
|
24
|
+
var _a, _b, _c, _d;
|
|
24
25
|
return ({
|
|
25
26
|
modelCall: (_a = o.modelCall) !== null && _a !== void 0 ? _a : 0,
|
|
26
27
|
turn: (_b = o.turn) !== null && _b !== void 0 ? _b : 0,
|
|
27
28
|
activationEnded: (_c = o.activationEnded) !== null && _c !== void 0 ? _c : (() => false),
|
|
29
|
+
phaseEnded: (_d = o.phaseEnded) !== null && _d !== void 0 ? _d : (() => false),
|
|
28
30
|
});
|
|
29
31
|
};
|
|
30
32
|
const sink = () => {
|
|
@@ -254,6 +256,45 @@ suite('agentEnd: kept while the agent is active, collapsed once its activation e
|
|
|
254
256
|
assert.is(events[0].trigger, 'agentEnd');
|
|
255
257
|
});
|
|
256
258
|
// ---------------------------------------------------------------------------
|
|
259
|
+
// phaseEnd
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
suite('phaseEnd: kept within the phase, collapsed once the app advances the epoch', () => {
|
|
262
|
+
// Registered in phase epoch 1.
|
|
263
|
+
const history = [asstCall('p1', 'load_entity'), toolMsg('p1', big())];
|
|
264
|
+
const policies = new Map([
|
|
265
|
+
['p1', reg({ on: { kind: 'phaseEnd' }, response: 'pointer' }, { phaseEpoch: 1 })],
|
|
266
|
+
]);
|
|
267
|
+
const { events, on } = sink();
|
|
268
|
+
// Still in phase 1 (no endPhase yet) — full.
|
|
269
|
+
const sameP = applyCondensation(history, policies, ctx({ phaseEnded: (e) => e < 1 }), on);
|
|
270
|
+
assert.is(sameP[1].toolResult.content, big(), 'full while the phase is current');
|
|
271
|
+
assert.is(events.length, 0);
|
|
272
|
+
// endPhase() advanced the current epoch to 2 → the phase-1 payload collapses.
|
|
273
|
+
const ended = applyCondensation(history, policies, ctx({ phaseEnded: (e) => e < 2 }), on);
|
|
274
|
+
assert.match(ended[1].toolResult.content, /phase ended/);
|
|
275
|
+
assert.match(ended[1].toolResult.content, /re-call to restore/);
|
|
276
|
+
assert.is(events.length, 1);
|
|
277
|
+
assert.is(events[0].trigger, 'phaseEnd');
|
|
278
|
+
});
|
|
279
|
+
suite('phaseEnd: falls back to agentEnd when no phase boundary is ever declared', () => {
|
|
280
|
+
// Phase epoch never advances (phaseEnded always false), but the activation ends.
|
|
281
|
+
const history = [asstCall('p1', 'load_entity'), toolMsg('p1', big())];
|
|
282
|
+
const policies = new Map([
|
|
283
|
+
[
|
|
284
|
+
'p1',
|
|
285
|
+
reg({ on: { kind: 'phaseEnd' }, response: 'pointer' }, { activation: 1, phaseEpoch: 1 }),
|
|
286
|
+
],
|
|
287
|
+
]);
|
|
288
|
+
const { events, on } = sink();
|
|
289
|
+
const active = applyCondensation(history, policies, ctx({ phaseEnded: () => false, activationEnded: () => false }), on);
|
|
290
|
+
assert.is(active[1].toolResult.content, big(), 'full while both phase and agent are live');
|
|
291
|
+
assert.is(events.length, 0);
|
|
292
|
+
const disposed = applyCondensation(history, policies, ctx({ phaseEnded: () => false, activationEnded: (a) => a === 1 }), on);
|
|
293
|
+
assert.match(disposed[1].toolResult.content, /phase ended/, 'agentEnd backstop collapses it');
|
|
294
|
+
assert.is(events.length, 1);
|
|
295
|
+
assert.is(events[0].trigger, 'phaseEnd');
|
|
296
|
+
});
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
257
298
|
// multi-trigger (OR / first-wins)
|
|
258
299
|
// ---------------------------------------------------------------------------
|
|
259
300
|
suite('multi-trigger: collapses when ANY listed trigger fires', () => {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* 1. **Directly function-valued** — the lifecycle/dispatch hooks (`onActivate`,
|
|
6
6
|
* `onDeactivate`, `getDebugSnapshot`, `onUnresolvedTool`) and the function
|
|
7
7
|
* form of the per-turn resolvers (`systemPrompt`, `toolDefinitions`,
|
|
8
|
-
* `displayName`, `provider`, `temperature`, `toolChoice`, `
|
|
8
|
+
* `displayName`, `provider`, `temperature`, `toolChoice`, `cachePolicy`,
|
|
9
|
+
* `toolHandlers`).
|
|
9
10
|
* 2. **Object "handler bags" whose *values* are functions** — `toolHandlers` in
|
|
10
11
|
* its object form is `{ name: handler }`, so `typeof` is `'object'`, not
|
|
11
12
|
* `'function'`. A by-value check on the field alone misses it, leaking a live
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genesislcap/ai-assistant",
|
|
3
3
|
"description": "Genesis AI Assistant micro-frontend",
|
|
4
|
-
"version": "14.
|
|
4
|
+
"version": "14.470.0",
|
|
5
5
|
"license": "SEE LICENSE IN license.txt",
|
|
6
6
|
"main": "dist/esm/index.js",
|
|
7
7
|
"types": "dist/ai-assistant.d.ts",
|
|
@@ -64,24 +64,24 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@genesislcap/foundation-testing": "14.
|
|
68
|
-
"@genesislcap/genx": "14.
|
|
69
|
-
"@genesislcap/rollup-builder": "14.
|
|
70
|
-
"@genesislcap/ts-builder": "14.
|
|
71
|
-
"@genesislcap/uvu-playwright-builder": "14.
|
|
72
|
-
"@genesislcap/vite-builder": "14.
|
|
73
|
-
"@genesislcap/webpack-builder": "14.
|
|
67
|
+
"@genesislcap/foundation-testing": "14.470.0",
|
|
68
|
+
"@genesislcap/genx": "14.470.0",
|
|
69
|
+
"@genesislcap/rollup-builder": "14.470.0",
|
|
70
|
+
"@genesislcap/ts-builder": "14.470.0",
|
|
71
|
+
"@genesislcap/uvu-playwright-builder": "14.470.0",
|
|
72
|
+
"@genesislcap/vite-builder": "14.470.0",
|
|
73
|
+
"@genesislcap/webpack-builder": "14.470.0",
|
|
74
74
|
"@types/dompurify": "^3.0.5",
|
|
75
75
|
"@types/marked": "^5.0.2"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@genesislcap/foundation-ai": "14.
|
|
79
|
-
"@genesislcap/foundation-logger": "14.
|
|
80
|
-
"@genesislcap/foundation-redux": "14.
|
|
81
|
-
"@genesislcap/foundation-ui": "14.
|
|
82
|
-
"@genesislcap/foundation-utils": "14.
|
|
83
|
-
"@genesislcap/rapid-design-system": "14.
|
|
84
|
-
"@genesislcap/web-core": "14.
|
|
78
|
+
"@genesislcap/foundation-ai": "14.470.0",
|
|
79
|
+
"@genesislcap/foundation-logger": "14.470.0",
|
|
80
|
+
"@genesislcap/foundation-redux": "14.470.0",
|
|
81
|
+
"@genesislcap/foundation-ui": "14.470.0",
|
|
82
|
+
"@genesislcap/foundation-utils": "14.470.0",
|
|
83
|
+
"@genesislcap/rapid-design-system": "14.470.0",
|
|
84
|
+
"@genesislcap/web-core": "14.470.0",
|
|
85
85
|
"dompurify": "^3.3.1",
|
|
86
86
|
"marked": "^17.0.3"
|
|
87
87
|
},
|
|
@@ -93,5 +93,5 @@
|
|
|
93
93
|
"publishConfig": {
|
|
94
94
|
"access": "public"
|
|
95
95
|
},
|
|
96
|
-
"gitHead": "
|
|
96
|
+
"gitHead": "c222968b05dd3bb5db608222696b1f59a1c65913"
|
|
97
97
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AIProvider,
|
|
3
3
|
AIProviderRegistry,
|
|
4
|
+
CachePolicy,
|
|
4
5
|
ChatMessage,
|
|
5
6
|
ChatRequestOptions,
|
|
6
7
|
ChatToolCall,
|
|
@@ -38,6 +39,12 @@ interface ScriptedProvider extends AIProvider {
|
|
|
38
39
|
toolChoicePerCall: Array<ChatToolChoice | undefined>;
|
|
39
40
|
/** `temperature` seen on each `chat()` call, in order. */
|
|
40
41
|
temperaturePerCall: Array<number | undefined>;
|
|
42
|
+
/** `cachePolicy` seen on each `chat()` call, in order. */
|
|
43
|
+
cachePolicyPerCall: Array<CachePolicy | undefined>;
|
|
44
|
+
/** `tailContext` seen on each `chat()` call, in order (already framed by the driver). */
|
|
45
|
+
tailContextPerCall: Array<string | undefined>;
|
|
46
|
+
/** `systemPrompt` seen on each `chat()` call, in order. */
|
|
47
|
+
systemPromptPerCall: Array<string | undefined>;
|
|
41
48
|
/** The model-bound history slice seen on each `chat()` call (post-transform). */
|
|
42
49
|
historyPerCall: ChatMessage[][];
|
|
43
50
|
}
|
|
@@ -47,11 +54,17 @@ const scriptedProvider = (responses: ChatMessage[]): ScriptedProvider => {
|
|
|
47
54
|
const advertisedPerCall: string[][] = [];
|
|
48
55
|
const toolChoicePerCall: Array<ChatToolChoice | undefined> = [];
|
|
49
56
|
const temperaturePerCall: Array<number | undefined> = [];
|
|
57
|
+
const cachePolicyPerCall: Array<CachePolicy | undefined> = [];
|
|
58
|
+
const tailContextPerCall: Array<string | undefined> = [];
|
|
59
|
+
const systemPromptPerCall: Array<string | undefined> = [];
|
|
50
60
|
const historyPerCall: ChatMessage[][] = [];
|
|
51
61
|
return {
|
|
52
62
|
advertisedPerCall,
|
|
53
63
|
toolChoicePerCall,
|
|
54
64
|
temperaturePerCall,
|
|
65
|
+
cachePolicyPerCall,
|
|
66
|
+
tailContextPerCall,
|
|
67
|
+
systemPromptPerCall,
|
|
55
68
|
historyPerCall,
|
|
56
69
|
chat: async (
|
|
57
70
|
history: ChatMessage[],
|
|
@@ -61,6 +74,9 @@ const scriptedProvider = (responses: ChatMessage[]): ScriptedProvider => {
|
|
|
61
74
|
advertisedPerCall.push((options?.tools ?? []).map((t) => t.name));
|
|
62
75
|
toolChoicePerCall.push(options?.toolChoice);
|
|
63
76
|
temperaturePerCall.push(options?.temperature);
|
|
77
|
+
cachePolicyPerCall.push(options?.cachePolicy);
|
|
78
|
+
tailContextPerCall.push(options?.tailContext);
|
|
79
|
+
systemPromptPerCall.push(options?.systemPrompt);
|
|
64
80
|
// Snapshot the slice (condensation produces fresh objects per call and never
|
|
65
81
|
// mutates stored history, so a shallow array copy is a stable per-call view).
|
|
66
82
|
historyPerCall.push([...history]);
|
|
@@ -1077,16 +1093,31 @@ const settings = createLogicSuite('ChatDriver temperature & toolChoice');
|
|
|
1077
1093
|
|
|
1078
1094
|
settings('passes a static agent temperature and tool-call mode to the provider', async () => {
|
|
1079
1095
|
const provider = scriptedProvider([]);
|
|
1080
|
-
const driver = makeDriver(
|
|
1096
|
+
const driver = makeDriver(
|
|
1097
|
+
agent({
|
|
1098
|
+
name: 'a',
|
|
1099
|
+
temperature: 0.3,
|
|
1100
|
+
toolChoice: 'none',
|
|
1101
|
+
cachePolicy: { scope: 'prompt' },
|
|
1102
|
+
tailContext: 'STATE',
|
|
1103
|
+
}),
|
|
1104
|
+
provider,
|
|
1105
|
+
);
|
|
1081
1106
|
|
|
1082
1107
|
await driver.sendMessage('hi');
|
|
1083
1108
|
|
|
1084
1109
|
assert.is(provider.temperaturePerCall[0], 0.3);
|
|
1085
1110
|
assert.equal(provider.toolChoicePerCall[0], 'none');
|
|
1111
|
+
assert.equal(provider.cachePolicyPerCall[0], { scope: 'prompt' });
|
|
1112
|
+
// The driver frames the raw tail content in a <system-reminder> marker.
|
|
1113
|
+
assert.is(provider.tailContextPerCall[0], '<system-reminder>\nSTATE\n</system-reminder>');
|
|
1086
1114
|
// ...and surfaced in the per-turn debug snapshot (the effective values sent).
|
|
1087
1115
|
const snap = driver.getTurnSnapshots()[0];
|
|
1088
1116
|
assert.is(snap.temperature, 0.3);
|
|
1089
1117
|
assert.equal(snap.toolChoice, 'none');
|
|
1118
|
+
// The framed tail is captured on the snapshot too — it is never stored in
|
|
1119
|
+
// history, so the snapshot is the only record of what the model actually saw.
|
|
1120
|
+
assert.is(snap.tailContext, '<system-reminder>\nSTATE\n</system-reminder>');
|
|
1090
1121
|
});
|
|
1091
1122
|
|
|
1092
1123
|
settings('re-resolves the function form per turn (carrying turn context)', async () => {
|
|
@@ -1098,6 +1129,7 @@ settings('re-resolves the function form per turn (carrying turn context)', async
|
|
|
1098
1129
|
// turnIndex: 0 on the first LLM call, > 0 on subsequent tool-loop iterations.
|
|
1099
1130
|
temperature: (ctx) => (ctx.turnIndex === 0 ? 0.1 : 0.9),
|
|
1100
1131
|
toolChoice: (ctx) => (ctx.turnIndex === 0 ? { tool: 'tool_a' } : 'auto'),
|
|
1132
|
+
cachePolicy: (ctx) => (ctx.turnIndex === 0 ? { scope: 'history' } : { scope: 'tools' }),
|
|
1101
1133
|
});
|
|
1102
1134
|
|
|
1103
1135
|
await makeDriver(config, provider).sendMessage('go');
|
|
@@ -1105,9 +1137,11 @@ settings('re-resolves the function form per turn (carrying turn context)', async
|
|
|
1105
1137
|
// First call forces the named tool at a low temperature...
|
|
1106
1138
|
assert.is(provider.temperaturePerCall[0], 0.1);
|
|
1107
1139
|
assert.equal(provider.toolChoicePerCall[0], { tool: 'tool_a' });
|
|
1140
|
+
assert.equal(provider.cachePolicyPerCall[0], { scope: 'history' });
|
|
1108
1141
|
// ...the follow-up call (after the tool result) sees the other branch.
|
|
1109
1142
|
assert.is(provider.temperaturePerCall[1], 0.9);
|
|
1110
1143
|
assert.equal(provider.toolChoicePerCall[1], 'auto');
|
|
1144
|
+
assert.equal(provider.cachePolicyPerCall[1], { scope: 'tools' });
|
|
1111
1145
|
});
|
|
1112
1146
|
|
|
1113
1147
|
settings(
|
|
@@ -1119,6 +1153,42 @@ settings(
|
|
|
1119
1153
|
|
|
1120
1154
|
assert.is(provider.temperaturePerCall[0], undefined);
|
|
1121
1155
|
assert.is(provider.toolChoicePerCall[0], undefined);
|
|
1156
|
+
assert.is(provider.cachePolicyPerCall[0], undefined);
|
|
1157
|
+
assert.is(provider.tailContextPerCall[0], undefined);
|
|
1158
|
+
},
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
// GENC-1380 A3b: the framework's malformed/empty retry nudge is routed to the framed tail (so the
|
|
1162
|
+
// system prompt stays byte-stable for caching), not appended to the system string.
|
|
1163
|
+
settings(
|
|
1164
|
+
'routes the empty-response retry nudge into the framed tail, not the system prompt',
|
|
1165
|
+
async () => {
|
|
1166
|
+
// First call returns empty → the driver retries; the second exhausts the queue and ends the turn.
|
|
1167
|
+
const provider = scriptedProvider([{ role: 'assistant', content: '' }]);
|
|
1168
|
+
|
|
1169
|
+
const driver = makeDriver(agent({ name: 'r', systemPrompt: 'BASE' }), provider);
|
|
1170
|
+
await driver.sendMessage('go');
|
|
1171
|
+
|
|
1172
|
+
// The retry call (index 1) carries the nudge in the framed tail...
|
|
1173
|
+
assert.ok(provider.tailContextPerCall[1]?.startsWith('<system-reminder>'), 'tail is framed');
|
|
1174
|
+
assert.ok(
|
|
1175
|
+
provider.tailContextPerCall[1]?.includes('You must respond'),
|
|
1176
|
+
'retry nudge is in the tail',
|
|
1177
|
+
);
|
|
1178
|
+
// ...and the system prompt stays the bare agent prompt on both calls (nudge moved, not copied).
|
|
1179
|
+
assert.is(provider.systemPromptPerCall[0], 'BASE');
|
|
1180
|
+
assert.is(provider.systemPromptPerCall[1], 'BASE');
|
|
1181
|
+
|
|
1182
|
+
// Regression guard (debug-log reconstruction): the tail is never stored in
|
|
1183
|
+
// history, so the per-turn snapshot must capture it — otherwise the retry
|
|
1184
|
+
// nudge the model actually received would be invisible in the export. The
|
|
1185
|
+
// snapshot's (systemPrompt + tailContext) must equal what the provider saw.
|
|
1186
|
+
const retrySnap = driver.getTurnSnapshots()[1];
|
|
1187
|
+
assert.is(retrySnap.systemPrompt, provider.systemPromptPerCall[1]);
|
|
1188
|
+
assert.is(retrySnap.tailContext, provider.tailContextPerCall[1]);
|
|
1189
|
+
assert.ok(retrySnap.tailContext?.includes('You must respond'), 'nudge reconstructable');
|
|
1190
|
+
// The first (non-retry) call had no framework suffix and no agent tail → empty.
|
|
1191
|
+
assert.is(driver.getTurnSnapshots()[0].tailContext, undefined);
|
|
1122
1192
|
},
|
|
1123
1193
|
);
|
|
1124
1194
|
|
|
@@ -1879,4 +1949,48 @@ condense('agentEnd: collapses once the agent releases (flow complete)', async ()
|
|
|
1879
1949
|
assert.is(events[0].detail?.trigger, 'agentEnd');
|
|
1880
1950
|
});
|
|
1881
1951
|
|
|
1952
|
+
condense('phaseEnd: collapses at endPhase() while the agent keeps running', async () => {
|
|
1953
|
+
const sessionKey = 'condense-phase-end';
|
|
1954
|
+
clearMetaEventRegistry();
|
|
1955
|
+
const provider = scriptedProvider([
|
|
1956
|
+
{ role: 'assistant', content: '', toolCalls: [{ id: 's1', name: 'load', args: {} }] }, // call 1
|
|
1957
|
+
{ role: 'assistant', content: '', toolCalls: [{ id: 'a1', name: 'advance', args: {} }] }, // call 2 — ends the phase
|
|
1958
|
+
{ role: 'assistant', content: 'continuing' }, // call 3 — agent still active, just answers
|
|
1959
|
+
]);
|
|
1960
|
+
const config = agent({
|
|
1961
|
+
name: 'journey',
|
|
1962
|
+
systemPrompt: 'a multi-phase flow',
|
|
1963
|
+
toolDefinitions: [def('load'), def('advance')],
|
|
1964
|
+
toolHandlers: {
|
|
1965
|
+
load: async (_args, ctx) => {
|
|
1966
|
+
ctx.condenseWhen({ on: { kind: 'phaseEnd' }, response: 'pointer' });
|
|
1967
|
+
return bigBody;
|
|
1968
|
+
},
|
|
1969
|
+
advance: async (_args, ctx) => {
|
|
1970
|
+
// Declares a phase boundary WITHOUT ending the agent (no releaseAgent).
|
|
1971
|
+
ctx.endPhase?.();
|
|
1972
|
+
return 'next phase';
|
|
1973
|
+
},
|
|
1974
|
+
},
|
|
1975
|
+
});
|
|
1976
|
+
const driver = makeDriver(config, provider, sessionKey);
|
|
1977
|
+
const result = await driver.sendMessage('run the flow');
|
|
1978
|
+
// The agent never released — the turn ends normally on the text response.
|
|
1979
|
+
assert.is(result.reason, 'done');
|
|
1980
|
+
|
|
1981
|
+
// call #2 (index 1) ran before `advance` ended the phase — result still full.
|
|
1982
|
+
const beforePhaseEnd = provider.historyPerCall[1].find((m) => m.toolResult?.toolCallId === 's1');
|
|
1983
|
+
assert.is(beforePhaseEnd!.toolResult!.content, bigBody, 'full while the phase is current');
|
|
1984
|
+
// call #3 (index 2) ran after endPhase() — collapsed. The activation never ended
|
|
1985
|
+
// (no release/swap), so this collapse can ONLY be the phase boundary, not the
|
|
1986
|
+
// agentEnd backstop.
|
|
1987
|
+
const afterPhaseEnd = provider.historyPerCall[2].find((m) => m.toolResult?.toolCallId === 's1');
|
|
1988
|
+
assert.match(afterPhaseEnd!.toolResult!.content, /re-call to restore/, 'collapsed at phase end');
|
|
1989
|
+
assert.match(afterPhaseEnd!.toolResult!.content, /phase ended/);
|
|
1990
|
+
|
|
1991
|
+
const events = getMetaEvents(sessionKey).filter((e) => e.type === 'context.condensed');
|
|
1992
|
+
assert.is(events.length, 1);
|
|
1993
|
+
assert.is(events[0].detail?.trigger, 'phaseEnd');
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1882
1996
|
condense.run();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AIProvider,
|
|
3
3
|
AIProviderRegistry,
|
|
4
|
+
CachePolicy,
|
|
4
5
|
ChatAttachment,
|
|
5
6
|
ChatDriverResult,
|
|
6
7
|
ChatMessage,
|
|
@@ -19,13 +20,16 @@ import type {
|
|
|
19
20
|
import {
|
|
20
21
|
isObservableAIProviderRegistry,
|
|
21
22
|
MalformedFunctionCallError,
|
|
23
|
+
ResponseTruncatedError,
|
|
22
24
|
} from '@genesislcap/foundation-ai';
|
|
23
25
|
import { agenticActivityBus } from '../../channel/ai-activity-bus';
|
|
24
26
|
import type {
|
|
25
27
|
AgentConfig,
|
|
28
|
+
CachePolicyInput,
|
|
26
29
|
ProviderInput,
|
|
27
30
|
SystemPromptContext,
|
|
28
31
|
SystemPromptInput,
|
|
32
|
+
TailContextInput,
|
|
29
33
|
TemperatureInput,
|
|
30
34
|
ToolChoiceInput,
|
|
31
35
|
ToolDefinitionsInput,
|
|
@@ -130,8 +134,22 @@ export interface TurnSnapshot {
|
|
|
130
134
|
timestamp: string;
|
|
131
135
|
/** Name of the agent active when this LLM call ran. */
|
|
132
136
|
agentName?: string;
|
|
133
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* The agent's resolved system prompt sent to the LLM, verbatim. NOTE: this is
|
|
139
|
+
* now the *bare* prompt — the fold-surface suffix and the malformed/empty retry
|
|
140
|
+
* nudge no longer mutate it (they kept it byte-unstable and busted prompt
|
|
141
|
+
* caching), so they moved to {@link TurnSnapshot.tailContext}. To reconstruct
|
|
142
|
+
* exactly what the model saw, read both fields.
|
|
143
|
+
*/
|
|
134
144
|
systemPrompt?: string;
|
|
145
|
+
/**
|
|
146
|
+
* The framed `<system-reminder>` tail appended to the final user turn for this
|
|
147
|
+
* call, verbatim — never part of stored history, so this snapshot is the only
|
|
148
|
+
* record of it. Carries the framework's volatile additions (fold suffix, retry
|
|
149
|
+
* nudge) and the agent's per-turn `tailContext` (e.g. current file spec, live
|
|
150
|
+
* diagnostics). Undefined when the tail was empty (the normal case).
|
|
151
|
+
*/
|
|
152
|
+
tailContext?: string;
|
|
135
153
|
/** Tool names sent to the LLM, in order — definitions are static per name so names alone suffice. */
|
|
136
154
|
toolNames: string[];
|
|
137
155
|
/**
|
|
@@ -282,6 +300,15 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
282
300
|
* collapsed until IT ends.
|
|
283
301
|
*/
|
|
284
302
|
private currentActivation = 0;
|
|
303
|
+
/**
|
|
304
|
+
* Monotonic phase-epoch counter — the `phaseEnd` clock. Advanced by the
|
|
305
|
+
* handler-context `endPhase()` (an app-declared phase boundary within one
|
|
306
|
+
* activation), never reset. Unlike `currentActivation` it is NOT tied to agent
|
|
307
|
+
* swaps or releases — it ticks only when the agent itself declares a phase
|
|
308
|
+
* done. `phaseEnd` fires for a payload once a LATER epoch is current
|
|
309
|
+
* (`callPhaseEpoch < currentPhaseEpoch`), with an `agentEnd` backstop.
|
|
310
|
+
*/
|
|
311
|
+
private currentPhaseEpoch = 0;
|
|
285
312
|
|
|
286
313
|
/** Stack of fold frames — grows when a fold opens, shrinks when it closes. */
|
|
287
314
|
private foldStack: FoldStackFrame[] = [];
|
|
@@ -395,6 +422,16 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
395
422
|
* call; top-level turns are `'auto'`).
|
|
396
423
|
*/
|
|
397
424
|
private activeToolChoiceInput?: ToolChoiceInput;
|
|
425
|
+
/**
|
|
426
|
+
* Active agent's prompt-cache policy selector (static value or per-turn resolver).
|
|
427
|
+
* `undefined` requests no caching (equivalent to `{ scope: 'default' }`).
|
|
428
|
+
*/
|
|
429
|
+
private activeCachePolicyInput?: CachePolicyInput;
|
|
430
|
+
/**
|
|
431
|
+
* Active agent's tail-context selector (static value or per-turn resolver). The driver frames
|
|
432
|
+
* the resolved string in a `<system-reminder>` marker and injects it at the message tail.
|
|
433
|
+
*/
|
|
434
|
+
private activeTailContextInput?: TailContextInput;
|
|
398
435
|
/**
|
|
399
436
|
* Active agent's unresolved-tool hook, captured from `applyAgent`. Consulted
|
|
400
437
|
* only when a tool call cannot be dispatched (a stale or hallucinated name);
|
|
@@ -602,6 +639,8 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
602
639
|
this.activeProviderInput = config.provider;
|
|
603
640
|
this.activeTemperatureInput = config.temperature;
|
|
604
641
|
this.activeToolChoiceInput = config.toolChoice;
|
|
642
|
+
this.activeCachePolicyInput = config.cachePolicy;
|
|
643
|
+
this.activeTailContextInput = config.tailContext;
|
|
605
644
|
this.activeOnUnresolvedTool = config.onUnresolvedTool;
|
|
606
645
|
this.resolvedProviderCache.clear();
|
|
607
646
|
this.lastResolvedProviderName = undefined;
|
|
@@ -851,6 +890,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
851
890
|
resolvedSystemPrompt: string | undefined,
|
|
852
891
|
temperature: number | undefined,
|
|
853
892
|
toolChoice: ChatToolChoice | undefined,
|
|
893
|
+
tailContext: string | undefined,
|
|
854
894
|
): void {
|
|
855
895
|
let agentSnapshot: unknown;
|
|
856
896
|
if (this.debugSnapshotter) {
|
|
@@ -871,6 +911,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
871
911
|
agentName: this.activeAgentName,
|
|
872
912
|
agentLabel: this.activeAgentLabel,
|
|
873
913
|
systemPrompt: resolvedSystemPrompt,
|
|
914
|
+
tailContext,
|
|
874
915
|
toolNames: this.toolDefinitions.map((t) => t.name),
|
|
875
916
|
temperature,
|
|
876
917
|
toolChoice,
|
|
@@ -1316,6 +1357,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1316
1357
|
// payloads collapse and a re-run starts a fresh activation.
|
|
1317
1358
|
this.currentActivation += 1;
|
|
1318
1359
|
},
|
|
1360
|
+
endPhase: (): void => {
|
|
1361
|
+
// Advance the phase epoch so payloads registered with `{ on: phaseEnd }`
|
|
1362
|
+
// during the phase that just ended collapse on the next provider call.
|
|
1363
|
+
// Repeatable (no once-only guard, unlike releaseAgent/completeSubAgent):
|
|
1364
|
+
// each call opens a fresh phase for subsequent reads to accumulate in.
|
|
1365
|
+
this.currentPhaseEpoch += 1;
|
|
1366
|
+
},
|
|
1319
1367
|
condenseWhen: (policy: CondensePolicy): void => {
|
|
1320
1368
|
// No addressable tool call (e.g. fold-close handler) — nothing to attach to.
|
|
1321
1369
|
if (!activeToolCallId) return;
|
|
@@ -1331,7 +1379,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1331
1379
|
? typeof t.by === 'string' && t.by.length > 0
|
|
1332
1380
|
: t?.kind === 'age'
|
|
1333
1381
|
? typeof t.turns === 'number' && t.turns >= 1
|
|
1334
|
-
: t?.kind === 'turnEnd' || t?.kind === 'agentEnd';
|
|
1382
|
+
: t?.kind === 'turnEnd' || t?.kind === 'agentEnd' || t?.kind === 'phaseEnd';
|
|
1335
1383
|
if (triggers.length === 0 || !triggers.every(validTrigger)) {
|
|
1336
1384
|
logger.warn(
|
|
1337
1385
|
`ChatDriver(${this.activeAgentName ?? 'unknown'}): condenseWhen called with an invalid trigger — ignoring`,
|
|
@@ -1353,6 +1401,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1353
1401
|
iteration: this.modelCallSeq,
|
|
1354
1402
|
turn: this.turnSeq,
|
|
1355
1403
|
activation: this.currentActivation,
|
|
1404
|
+
phaseEpoch: this.currentPhaseEpoch,
|
|
1356
1405
|
});
|
|
1357
1406
|
},
|
|
1358
1407
|
};
|
|
@@ -1895,10 +1944,18 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1895
1944
|
this.activeAgentLabel = await this.displayName(promptCtx);
|
|
1896
1945
|
}
|
|
1897
1946
|
|
|
1947
|
+
// Volatile framework additions — the fold-surface suffix and the per-attempt malformed/empty
|
|
1948
|
+
// retry nudge — that would otherwise destabilize a cached system prompt. They are routed to
|
|
1949
|
+
// the framed tail (below), never appended to the system string, so the system prompt stays
|
|
1950
|
+
// byte-stable for every agent.
|
|
1898
1951
|
const foldSuffix = this.buildFoldSystemPromptSuffix();
|
|
1899
|
-
const
|
|
1900
|
-
|
|
1901
|
-
|
|
1952
|
+
const retryNudge =
|
|
1953
|
+
malformedAttempts > 0
|
|
1954
|
+
? '\n\nIMPORTANT: Use only the structured function-call API to invoke tools. Do not write Python code or use Python-style syntax to call tools.'
|
|
1955
|
+
: emptyResponseAttempts > 0
|
|
1956
|
+
? "\n\nIMPORTANT: You must respond to the user's message. Call the appropriate tool or provide a text response — do not return an empty response."
|
|
1957
|
+
: '';
|
|
1958
|
+
const frameworkSystemSuffix = `${foldSuffix}${retryNudge}`;
|
|
1902
1959
|
|
|
1903
1960
|
const primer = [...(this.primerHistory ?? []), ...(transientPrimer ?? [])];
|
|
1904
1961
|
const baseHistory = firstLlmCall ? this.history.slice(0, -1) : this.history;
|
|
@@ -1915,6 +1972,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1915
1972
|
// A call's activation has ended once a later activation is current — a
|
|
1916
1973
|
// swap or release/complete both advance the counter.
|
|
1917
1974
|
activationEnded: (activation) => activation < this.currentActivation,
|
|
1975
|
+
// A call's phase has ended once the app declared a later phase epoch
|
|
1976
|
+
// current via `endPhase()`.
|
|
1977
|
+
phaseEnded: (phaseEpoch) => phaseEpoch < this.currentPhaseEpoch,
|
|
1918
1978
|
},
|
|
1919
1979
|
(detail) => recordMetaEvent(this.sessionKey, 'context.condensed', detail),
|
|
1920
1980
|
);
|
|
@@ -1923,29 +1983,38 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1923
1983
|
: condensedHistory;
|
|
1924
1984
|
const historyForCall = [...primer, ...historyForProvider];
|
|
1925
1985
|
|
|
1926
|
-
const systemPrompt =
|
|
1927
|
-
malformedAttempts > 0
|
|
1928
|
-
? `${baseSystemPrompt ?? ''}\n\nIMPORTANT: Use only the structured function-call API to invoke tools. Do not write Python code or use Python-style syntax to call tools.`
|
|
1929
|
-
: emptyResponseAttempts > 0
|
|
1930
|
-
? `${baseSystemPrompt ?? ''}\n\nIMPORTANT: You must respond to the user's message. Call the appropriate tool or provide a text response — do not return an empty response.`
|
|
1931
|
-
: baseSystemPrompt;
|
|
1932
|
-
|
|
1933
1986
|
// Resolve the per-turn temperature and tool-call mode the same way the
|
|
1934
1987
|
// provider is resolved — static value or a function of the turn context
|
|
1935
1988
|
// (which carries the live state for stateful agents). Resolved before the
|
|
1936
1989
|
// snapshot so the debug log records the exact request config the model saw.
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1990
|
+
const [resolvedTemperature, resolvedToolChoice, resolvedCachePolicy, resolvedTailContext] =
|
|
1991
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
1992
|
+
await Promise.all([
|
|
1993
|
+
this.resolveTurnInput<number>(this.activeTemperatureInput, promptCtx),
|
|
1994
|
+
this.resolveTurnInput<ChatToolChoice>(this.activeToolChoiceInput, promptCtx),
|
|
1995
|
+
this.resolveTurnInput<CachePolicy>(this.activeCachePolicyInput, promptCtx),
|
|
1996
|
+
this.resolveTurnInput<string>(this.activeTailContextInput, promptCtx),
|
|
1997
|
+
]);
|
|
1998
|
+
// The system prompt is always just the agent's resolved prompt — byte-stable, so it can be
|
|
1999
|
+
// cached. The framework's volatile additions (fold suffix, retry nudge) and the agent's tail
|
|
2000
|
+
// context all go to the framed tail: one uniform channel, no cache-scope branch. On a normal
|
|
2001
|
+
// turn `frameworkSystemSuffix` is empty, so the tail is empty too — a no-op. The framework
|
|
2002
|
+
// owns the framing; the corrective retry nudge lands last (recency).
|
|
2003
|
+
const systemPrompt = resolvedSystemPrompt || undefined;
|
|
2004
|
+
const tailBody = [resolvedTailContext, frameworkSystemSuffix]
|
|
2005
|
+
.map((part) => (part ?? '').trim())
|
|
2006
|
+
.filter((part) => part.length > 0)
|
|
2007
|
+
.join('\n\n');
|
|
2008
|
+
const tailContext = tailBody
|
|
2009
|
+
? `<system-reminder>\n${tailBody}\n</system-reminder>`
|
|
2010
|
+
: undefined;
|
|
1942
2011
|
// An agent/state-configured tool-call mode wins. Otherwise sub-agents must
|
|
1943
2012
|
// finish by calling a tool (their completion tool) so the turn can't end
|
|
1944
2013
|
// on a free-text answer; top-level agents stay 'auto'. (Transports no-op a
|
|
1945
2014
|
// force when no tools are advertised.)
|
|
1946
2015
|
const effectiveToolChoice = resolvedToolChoice ?? (this.isSubAgent ? 'required' : undefined);
|
|
1947
2016
|
|
|
1948
|
-
this.recordTurnSnapshot(systemPrompt, resolvedTemperature, effectiveToolChoice);
|
|
2017
|
+
this.recordTurnSnapshot(systemPrompt, resolvedTemperature, effectiveToolChoice, tailContext);
|
|
1949
2018
|
|
|
1950
2019
|
// Capture the pending user input, then clear the slots BEFORE the chat
|
|
1951
2020
|
// call. `sendMessage` already appended the user message to `this.history`,
|
|
@@ -1970,6 +2039,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1970
2039
|
// Agent/state-configured sampling temperature (normalized 0–1); each
|
|
1971
2040
|
// transport translates it to its native range. Undefined → provider default.
|
|
1972
2041
|
temperature: resolvedTemperature,
|
|
2042
|
+
// Prompt-cache policy for this turn (Anthropic places breakpoints per scope; Gemini
|
|
2043
|
+
// caches implicitly regardless). Undefined → no caching requested.
|
|
2044
|
+
cachePolicy: resolvedCachePolicy,
|
|
2045
|
+
// Framed volatile context injected at the message tail (never stored). Undefined → none.
|
|
2046
|
+
tailContext,
|
|
1973
2047
|
};
|
|
1974
2048
|
|
|
1975
2049
|
// Resolve the active provider for this turn. Static names were validated
|
|
@@ -2019,6 +2093,35 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
2019
2093
|
}
|
|
2020
2094
|
return { reason: 'done' };
|
|
2021
2095
|
}
|
|
2096
|
+
// The response was truncated at the provider's output-token cap while it
|
|
2097
|
+
// still carried a tool call — its arguments are incomplete and unusable.
|
|
2098
|
+
// Unlike a malformed call this is DETERMINISTIC: re-issuing the same
|
|
2099
|
+
// request hits the same cap, so a silent retry just loops. Bail with a
|
|
2100
|
+
// clear, actionable signal instead. The remedy is config (raise the
|
|
2101
|
+
// provider maxTokens) or a smaller step — neither is fixed by retrying.
|
|
2102
|
+
if (e instanceof ResponseTruncatedError) {
|
|
2103
|
+
logger.error('ChatDriver: response truncated at the output-token cap', e);
|
|
2104
|
+
recordTurnError(this.sessionKey, 'response-truncated', {
|
|
2105
|
+
agent: this.activeAgentName,
|
|
2106
|
+
provider: this.lastResolvedProviderName,
|
|
2107
|
+
model: e.model,
|
|
2108
|
+
maxTokens: e.maxTokens,
|
|
2109
|
+
outputTokens: e.outputTokens,
|
|
2110
|
+
tools: e.toolNames,
|
|
2111
|
+
isSubAgent: this.isSubAgent,
|
|
2112
|
+
});
|
|
2113
|
+
if (this.isSubAgent) {
|
|
2114
|
+
// Bubble a typed failure to the parent instead of speaking to the user.
|
|
2115
|
+
this.failSubAgent('response_truncated');
|
|
2116
|
+
} else {
|
|
2117
|
+
this.appendToHistory({
|
|
2118
|
+
role: 'assistant',
|
|
2119
|
+
content:
|
|
2120
|
+
'My response was cut off because a single step reached the model output limit. This usually means one step tried to produce too much at once — try breaking your request into smaller steps.',
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
return { reason: 'done' };
|
|
2124
|
+
}
|
|
2022
2125
|
// A request timeout from the transport (tagged `TimeoutError`) is not a
|
|
2023
2126
|
// bug on our end — surface it distinctly instead of letting it fall
|
|
2024
2127
|
// through to the generic "something went wrong" catch. No auto-retry:
|