@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.
Files changed (33) hide show
  1. package/dist/ai-assistant.api.json +272 -3
  2. package/dist/ai-assistant.d.ts +95 -1
  3. package/dist/dts/components/chat-driver/chat-driver.d.ts +34 -1
  4. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  5. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  6. package/dist/dts/config/config.d.ts +42 -2
  7. package/dist/dts/config/config.d.ts.map +1 -1
  8. package/dist/dts/config/define-stateful-agent.d.ts +14 -1
  9. package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
  10. package/dist/dts/main/main.d.ts +3 -0
  11. package/dist/dts/main/main.d.ts.map +1 -1
  12. package/dist/dts/state/debug-event-log.d.ts +3 -1
  13. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  14. package/dist/dts/utils/condense-history.d.ts +10 -0
  15. package/dist/dts/utils/condense-history.d.ts.map +1 -1
  16. package/dist/dts/utils/strip-agent-handlers.d.ts +2 -1
  17. package/dist/dts/utils/strip-agent-handlers.d.ts.map +1 -1
  18. package/dist/esm/components/chat-driver/chat-driver.js +87 -13
  19. package/dist/esm/components/chat-driver/chat-driver.test.js +95 -1
  20. package/dist/esm/config/define-stateful-agent.js +18 -0
  21. package/dist/esm/state/debug-event-log.js +1 -1
  22. package/dist/esm/utils/condense-history.js +11 -1
  23. package/dist/esm/utils/condense-history.test.js +43 -2
  24. package/dist/esm/utils/strip-agent-handlers.js +2 -1
  25. package/package.json +16 -16
  26. package/src/components/chat-driver/chat-driver.test.ts +115 -1
  27. package/src/components/chat-driver/chat-driver.ts +121 -18
  28. package/src/config/config.ts +46 -1
  29. package/src/config/define-stateful-agent.ts +47 -0
  30. package/src/state/debug-event-log.ts +5 -2
  31. package/src/utils/condense-history.test.ts +65 -2
  32. package/src/utils/condense-history.ts +18 -1
  33. 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`, `toolHandlers`).
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.469.0",
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.469.0",
68
- "@genesislcap/genx": "14.469.0",
69
- "@genesislcap/rollup-builder": "14.469.0",
70
- "@genesislcap/ts-builder": "14.469.0",
71
- "@genesislcap/uvu-playwright-builder": "14.469.0",
72
- "@genesislcap/vite-builder": "14.469.0",
73
- "@genesislcap/webpack-builder": "14.469.0",
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.469.0",
79
- "@genesislcap/foundation-logger": "14.469.0",
80
- "@genesislcap/foundation-redux": "14.469.0",
81
- "@genesislcap/foundation-ui": "14.469.0",
82
- "@genesislcap/foundation-utils": "14.469.0",
83
- "@genesislcap/rapid-design-system": "14.469.0",
84
- "@genesislcap/web-core": "14.469.0",
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": "2b4460d5b9f20c36baddc55084e658bbcbd18c89"
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(agent({ name: 'a', temperature: 0.3, toolChoice: 'none' }), provider);
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
- /** Final system prompt sent to the LLM (post-fold-suffix, post-retry hint). */
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 baseSystemPrompt = resolvedSystemPrompt
1900
- ? `${resolvedSystemPrompt}${foldSuffix}`
1901
- : foldSuffix || undefined;
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
- // oxlint-disable-next-line no-await-in-loop
1938
- const [resolvedTemperature, resolvedToolChoice] = await Promise.all([
1939
- this.resolveTurnInput<number>(this.activeTemperatureInput, promptCtx),
1940
- this.resolveTurnInput<ChatToolChoice>(this.activeToolChoiceInput, promptCtx),
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: