@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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type { ChatMessage, CondensePolicy, CondenseTrigger } from '@genesislcap/foundation-ai';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tool-context condensation: collapse stale tool payloads out of the history
|
|
5
|
+
* sent to the model, while leaving stored history untouched.
|
|
6
|
+
*
|
|
7
|
+
* The driver owns the registry (tool handlers populate it via `condenseWhen`)
|
|
8
|
+
* and the cadence (this runs before every provider call). This module owns the
|
|
9
|
+
* pure application: given the registry + the current loop iteration, produce a
|
|
10
|
+
* rewritten copy of the history with stale args/results replaced by stubs. Kept
|
|
11
|
+
* pure (no driver/session state, no logging) so it unit-tests in isolation —
|
|
12
|
+
* the one-time `context.condensed` signal is delivered via an `onCondensed`
|
|
13
|
+
* callback rather than reaching into the debug-event log directly.
|
|
14
|
+
*
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Below this serialized size a tool payload isn't worth condensing — the stub
|
|
20
|
+
* would save little and cost a breadcrumb. A driver-level floor so the public
|
|
21
|
+
* `condenseWhen` API needs no per-policy threshold field.
|
|
22
|
+
*/
|
|
23
|
+
export const CONDENSE_MIN_CHARS = 1000;
|
|
24
|
+
|
|
25
|
+
/** Key the collapsed args are stored under — a tool-call's args must stay a Record. */
|
|
26
|
+
export const CONDENSED_ARGS_KEY = 'condensed';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rough chars-per-token divisor for the `tokensSaved` estimate. No tokenizer is
|
|
30
|
+
* available in this stack; ~4 chars/token is the usual English approximation.
|
|
31
|
+
*/
|
|
32
|
+
const APPROX_CHARS_PER_TOKEN = 4;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A `condenseWhen` policy registered against a tool-call id, with its age clock
|
|
36
|
+
* and report-once flags. The driver holds these in a `Map<toolCallId, …>`;
|
|
37
|
+
* {@link applyCondensation} reads them and flips the `reported*` flags so the
|
|
38
|
+
* `context.condensed` signal fires once per payload, not once per provider call.
|
|
39
|
+
*/
|
|
40
|
+
export interface RegisteredCondensePolicy {
|
|
41
|
+
policy: CondensePolicy;
|
|
42
|
+
/**
|
|
43
|
+
* Monotonic model-call sequence (`modelCallSeq`) when `condenseWhen` was called
|
|
44
|
+
* — the origin of the age clock. Driver-lifetime and NOT reset per turn, so age
|
|
45
|
+
* is measured consistently across turn boundaries (the field name is historical;
|
|
46
|
+
* it is not the per-turn loop iteration).
|
|
47
|
+
*/
|
|
48
|
+
iteration: number;
|
|
49
|
+
/** Turn (`sendMessage`) sequence when the call was made — the `turnEnd` clock. */
|
|
50
|
+
turn: number;
|
|
51
|
+
/** Agent-activation sequence when the call was made — the `agentEnd` clock. */
|
|
52
|
+
activation: number;
|
|
53
|
+
/** `context.condensed` already emitted for the args payload (fire-once). */
|
|
54
|
+
reportedArgs?: boolean;
|
|
55
|
+
/** `context.condensed` already emitted for the response payload (fire-once). */
|
|
56
|
+
reportedResponse?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The driver's live state at the moment of a provider call — the clocks every
|
|
61
|
+
* trigger is evaluated against. Passed by the driver; kept as an interface so the
|
|
62
|
+
* transform stays a pure function of (history, policies, clocks).
|
|
63
|
+
*/
|
|
64
|
+
export interface CondenseContext {
|
|
65
|
+
/** Monotonic model-call sequence now — the `age` clock. */
|
|
66
|
+
modelCall: number;
|
|
67
|
+
/** Monotonic turn (`sendMessage`) sequence now — the `turnEnd` clock. */
|
|
68
|
+
turn: number;
|
|
69
|
+
/** Whether the agent activation that made a call has since ended — the `agentEnd` clock. */
|
|
70
|
+
activationEnded: (activation: number) => boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Structured detail for a `context.condensed` meta-event. The index signature
|
|
75
|
+
* lets it satisfy `recordMetaEvent`'s `Record<string, unknown>` detail param
|
|
76
|
+
* while the named fields stay strictly typed.
|
|
77
|
+
*/
|
|
78
|
+
export interface CondensedEventDetail {
|
|
79
|
+
[key: string]: unknown;
|
|
80
|
+
/** Tool-loop iteration the collapsed call was made on. */
|
|
81
|
+
turn: number;
|
|
82
|
+
toolCallId: string;
|
|
83
|
+
tool: string;
|
|
84
|
+
target: 'args' | 'response';
|
|
85
|
+
/** Human-readable trigger, e.g. `superseded:Foo.tsx` or `age:3`. */
|
|
86
|
+
trigger: string;
|
|
87
|
+
/** Length of the replacement stub. */
|
|
88
|
+
stubLen: number;
|
|
89
|
+
/** Estimated input tokens saved this collapse (char/4 — no tokenizer in-stack). */
|
|
90
|
+
tokensSaved: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Short human-readable reason for the stub text, per fired trigger. */
|
|
94
|
+
function triggerReason(trigger: CondenseTrigger): string {
|
|
95
|
+
switch (trigger.kind) {
|
|
96
|
+
case 'age':
|
|
97
|
+
return 'aged out';
|
|
98
|
+
case 'turnEnd':
|
|
99
|
+
return 'turn ended';
|
|
100
|
+
case 'agentEnd':
|
|
101
|
+
return 'agent finished';
|
|
102
|
+
default:
|
|
103
|
+
return 'superseded';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Framework-generated `pointer` stub — advertises that re-calling restores the content. */
|
|
108
|
+
function condenseStub(
|
|
109
|
+
target: 'args' | 'response',
|
|
110
|
+
tool: string,
|
|
111
|
+
trigger: CondenseTrigger,
|
|
112
|
+
origLen: number,
|
|
113
|
+
): string {
|
|
114
|
+
const what = target === 'args' ? 'args' : 'result';
|
|
115
|
+
const key = trigger.kind === 'superseded' ? ` ${trigger.by}` : '';
|
|
116
|
+
return `[${tool}${key} — ${what} elided, ~${origLen} chars (${triggerReason(trigger)}); re-call to restore]`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Stable label for the `context.condensed` meta-event's `trigger` field. */
|
|
120
|
+
function triggerLabel(trigger: CondenseTrigger): string {
|
|
121
|
+
switch (trigger.kind) {
|
|
122
|
+
case 'age':
|
|
123
|
+
return `age:${trigger.turns}`;
|
|
124
|
+
case 'superseded':
|
|
125
|
+
return `superseded:${trigger.by}`;
|
|
126
|
+
default:
|
|
127
|
+
return trigger.kind; // 'turnEnd' | 'agentEnd'
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function estimateTokensSaved(origLen: number, stubLen: number): number {
|
|
132
|
+
return Math.max(0, Math.ceil((origLen - stubLen) / APPROX_CHARS_PER_TOKEN));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Collapse stale tool payloads (declared via `condenseWhen`) out of the
|
|
137
|
+
* model-bound history. Pure over the `history` array — it emits new message
|
|
138
|
+
* objects and never mutates the input messages (mirroring the agent-masking
|
|
139
|
+
* transform). It DOES, however, flip the `reported*` flag on the matching
|
|
140
|
+
* `policies` entry the first time a payload collapses — the report-once gate that
|
|
141
|
+
* keeps `onCondensed` firing once per (tool call, payload) even though the
|
|
142
|
+
* collapse itself re-runs before every provider call.
|
|
143
|
+
*
|
|
144
|
+
* Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
|
|
145
|
+
* collapses the payload; all are monotonic, so the collapse never reverts):
|
|
146
|
+
* - `superseded` — among all registered calls sharing a `by` key, the LAST in
|
|
147
|
+
* history order survives; every earlier one's targeted payload collapses. A
|
|
148
|
+
* re-call with the same key becomes the new survivor and re-arms the rest.
|
|
149
|
+
* - `age` — the result is first visible one model-call after the call, so `turns`
|
|
150
|
+
* is how many model-calls may see the full result before it collapses
|
|
151
|
+
* (fires when `modelCall − callModelCall > turns`): `turns: 1` is seen once,
|
|
152
|
+
* then collapses. The clock is monotonic across turns.
|
|
153
|
+
* - `turnEnd` — collapses once the turn (`sendMessage`) that made the call has
|
|
154
|
+
* ended (fires when `turn > callTurn`): full for the rest of that request,
|
|
155
|
+
* gone on every later one.
|
|
156
|
+
* - `agentEnd` — collapses once the agent activation that made the call has ended
|
|
157
|
+
* (a swap to another agent, or `releaseAgent`/`completeSubAgent`): kept across
|
|
158
|
+
* all of the agent's turns, dropped when its flow finishes.
|
|
159
|
+
*
|
|
160
|
+
* The tool-call/result envelope is never removed (providers reject orphaned
|
|
161
|
+
* calls/results) — only the args object or the result content is replaced.
|
|
162
|
+
*
|
|
163
|
+
* @param history - The to-model history copy to rewrite (not mutated).
|
|
164
|
+
* @param policies - The driver's registry, keyed by tool-call id. Its entries'
|
|
165
|
+
* `reported*` flags are flipped as payloads first collapse.
|
|
166
|
+
* @param ctx - The driver's live clocks (model-call / turn / agent-activation).
|
|
167
|
+
* @param onCondensed - Invoked once per payload at its full→stub transition.
|
|
168
|
+
*/
|
|
169
|
+
export function applyCondensation(
|
|
170
|
+
history: ChatMessage[],
|
|
171
|
+
policies: Map<string, RegisteredCondensePolicy>,
|
|
172
|
+
ctx: CondenseContext,
|
|
173
|
+
onCondensed: (detail: CondensedEventDetail) => void,
|
|
174
|
+
): ChatMessage[] {
|
|
175
|
+
if (policies.size === 0) return history;
|
|
176
|
+
|
|
177
|
+
const triggersOf = (entry: RegisteredCondensePolicy): CondenseTrigger[] =>
|
|
178
|
+
Array.isArray(entry.policy.on) ? entry.policy.on : [entry.policy.on];
|
|
179
|
+
|
|
180
|
+
// One pass: resolve supersession survivors and a toolCallId → tool-name
|
|
181
|
+
// lookup. For each `superseded` key the LAST call in history order survives.
|
|
182
|
+
// The name lookup lets a tool message (which carries only an id) name its tool
|
|
183
|
+
// in stubs and events.
|
|
184
|
+
const latestByKey = new Map<string, string>();
|
|
185
|
+
const nameById = new Map<string, string>();
|
|
186
|
+
for (const msg of history) {
|
|
187
|
+
if (!msg.toolCalls?.length) continue;
|
|
188
|
+
for (const tc of msg.toolCalls) {
|
|
189
|
+
nameById.set(tc.id, tc.name);
|
|
190
|
+
const entry = policies.get(tc.id);
|
|
191
|
+
if (!entry) continue;
|
|
192
|
+
for (const t of triggersOf(entry)) {
|
|
193
|
+
if (t.kind === 'superseded') latestByKey.set(t.by, tc.id);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const triggerFires = (
|
|
199
|
+
toolCallId: string,
|
|
200
|
+
entry: RegisteredCondensePolicy,
|
|
201
|
+
trig: CondenseTrigger,
|
|
202
|
+
): boolean => {
|
|
203
|
+
switch (trig.kind) {
|
|
204
|
+
// Result first visible one model-call after the call, so collapse one call
|
|
205
|
+
// past the last allowed view: turns:1 → seen once, then gone. Monotonic
|
|
206
|
+
// clock, so this holds across turn boundaries too.
|
|
207
|
+
case 'age':
|
|
208
|
+
return ctx.modelCall - entry.iteration > trig.turns;
|
|
209
|
+
case 'turnEnd':
|
|
210
|
+
return ctx.turn > entry.turn;
|
|
211
|
+
case 'agentEnd':
|
|
212
|
+
return ctx.activationEnded(entry.activation);
|
|
213
|
+
default:
|
|
214
|
+
return latestByKey.get(trig.by) !== toolCallId; // superseded by a newer call
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// The first listed trigger that fires — drives the collapse, the stub reason,
|
|
219
|
+
// and the event label. `undefined` means the payload stays full.
|
|
220
|
+
const firstFired = (
|
|
221
|
+
toolCallId: string,
|
|
222
|
+
entry: RegisteredCondensePolicy,
|
|
223
|
+
): CondenseTrigger | undefined =>
|
|
224
|
+
triggersOf(entry).find((t) => triggerFires(toolCallId, entry, t));
|
|
225
|
+
|
|
226
|
+
return history.map((msg) => {
|
|
227
|
+
// Tool-call ARGS live on the assistant message.
|
|
228
|
+
if (msg.toolCalls?.length) {
|
|
229
|
+
let changed = false;
|
|
230
|
+
const toolCalls = msg.toolCalls.map((tc) => {
|
|
231
|
+
const entry = policies.get(tc.id);
|
|
232
|
+
if (!entry?.policy.args) return tc;
|
|
233
|
+
const fired = firstFired(tc.id, entry);
|
|
234
|
+
if (!fired) return tc;
|
|
235
|
+
const origLen = JSON.stringify(tc.args ?? {}).length;
|
|
236
|
+
if (origLen < CONDENSE_MIN_CHARS) return tc;
|
|
237
|
+
changed = true;
|
|
238
|
+
const result = entry.policy.args;
|
|
239
|
+
// Args must stay a Record; `drop` is an empty object, otherwise stash the
|
|
240
|
+
// stub under a single key. Spread `tc` so providerMetadata (e.g. the
|
|
241
|
+
// Gemini reasoning signature that must round-trip) and UI fields survive.
|
|
242
|
+
const args: Record<string, unknown> =
|
|
243
|
+
result === 'drop'
|
|
244
|
+
? {}
|
|
245
|
+
: {
|
|
246
|
+
[CONDENSED_ARGS_KEY]:
|
|
247
|
+
result === 'pointer'
|
|
248
|
+
? condenseStub('args', tc.name, fired, origLen)
|
|
249
|
+
: result.replaceWith,
|
|
250
|
+
};
|
|
251
|
+
if (!entry.reportedArgs) {
|
|
252
|
+
entry.reportedArgs = true;
|
|
253
|
+
const stubLen = JSON.stringify(args).length;
|
|
254
|
+
onCondensed({
|
|
255
|
+
turn: entry.iteration,
|
|
256
|
+
toolCallId: tc.id,
|
|
257
|
+
tool: tc.name,
|
|
258
|
+
target: 'args',
|
|
259
|
+
trigger: triggerLabel(fired),
|
|
260
|
+
stubLen,
|
|
261
|
+
tokensSaved: estimateTokensSaved(origLen, stubLen),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return { ...tc, args };
|
|
265
|
+
});
|
|
266
|
+
return changed ? { ...msg, toolCalls } : msg;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The tool RESULT lives on the tool message.
|
|
270
|
+
if (msg.toolResult) {
|
|
271
|
+
const entry = policies.get(msg.toolResult.toolCallId);
|
|
272
|
+
// Defensive `?? 0` for restored/malformed history — `content` is typed
|
|
273
|
+
// `string`, but a 0-length payload is simply below the floor and skipped.
|
|
274
|
+
const origLen = msg.toolResult.content?.length ?? 0;
|
|
275
|
+
const fired = entry?.policy.response
|
|
276
|
+
? firstFired(msg.toolResult.toolCallId, entry)
|
|
277
|
+
: undefined;
|
|
278
|
+
if (entry && fired && origLen >= CONDENSE_MIN_CHARS) {
|
|
279
|
+
const tool = nameById.get(msg.toolResult.toolCallId) ?? msg.toolResult.toolCallId;
|
|
280
|
+
const result = entry.policy.response!; // `fired` is only set when response is declared
|
|
281
|
+
// Kept non-empty — Anthropic rejects empty tool_result content.
|
|
282
|
+
const content =
|
|
283
|
+
result === 'drop'
|
|
284
|
+
? '[elided]'
|
|
285
|
+
: result === 'pointer'
|
|
286
|
+
? condenseStub('response', tool, fired, origLen)
|
|
287
|
+
: result.replaceWith;
|
|
288
|
+
if (!entry.reportedResponse) {
|
|
289
|
+
entry.reportedResponse = true;
|
|
290
|
+
onCondensed({
|
|
291
|
+
turn: entry.iteration,
|
|
292
|
+
toolCallId: msg.toolResult.toolCallId,
|
|
293
|
+
tool,
|
|
294
|
+
target: 'response',
|
|
295
|
+
trigger: triggerLabel(fired),
|
|
296
|
+
stubLen: content.length,
|
|
297
|
+
tokensSaved: estimateTokensSaved(origLen, content.length),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return { ...msg, toolResult: { ...msg.toolResult, content } };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return msg;
|
|
305
|
+
});
|
|
306
|
+
}
|