@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.
@@ -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
+ }