@genesislcap/ai-assistant 15.5.0 → 15.6.1

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.
@@ -75,6 +75,24 @@ export interface CondenseContext {
75
75
  * `phaseEnd` clock. True once `endPhase()` has been called since the call.
76
76
  */
77
77
  phaseEnded: (phaseEpoch: number) => boolean;
78
+ /**
79
+ * Collapse in batches of this many model-calls instead of continuously. A positive
80
+ * integer; decimals are floored and anything below `1` becomes `1`. Omit (or `1`) to
81
+ * collapse as soon as a trigger fires, which is the historical behaviour.
82
+ *
83
+ * Condensation rewrites a payload *in place*, so the model-bound history is not
84
+ * append-only and every prompt-cache entry written before the rewrite is invalidated
85
+ * from that position on. Collapsing the moment each trigger fires therefore breaks the
86
+ * cache on nearly every call of a re-read loop — the case condensation exists to
87
+ * contain. Batching holds the collapse set still between boundaries so the prefix is
88
+ * append-only in between, trading up to `batchCalls` calls' worth of extra context for
89
+ * one cache break per batch instead of one per call.
90
+ *
91
+ * `agentEnd` and `phaseEnd` are exempt and still collapse as they fire: they mark a
92
+ * discontinuity that invalidates the cached prefix anyway, so deferring them would hold
93
+ * stale payloads longer for nothing.
94
+ */
95
+ batchCalls?: number;
78
96
  }
79
97
 
80
98
  /**
@@ -141,6 +159,24 @@ function estimateTokensSaved(origLen: number, stubLen: number): number {
141
159
  return Math.max(0, Math.ceil((origLen - stubLen) / APPROX_CHARS_PER_TOKEN));
142
160
  }
143
161
 
162
+ /**
163
+ * Whether a trigger marks a genuine discontinuity rather than firing continuously, and so is
164
+ * exempt from batching.
165
+ *
166
+ * `agentEnd` advances only when the active agent's name changes (or a sub-agent completes /
167
+ * the agent is released), which swaps the system prompt and tool definitions — those render
168
+ * before the messages, so the cached prefix is invalidated at that point regardless. `phaseEnd`
169
+ * is the app declaring the same kind of boundary explicitly, and falls back to `agentEnd`.
170
+ * Deferring either to the next batch would buy no cache and only hold stale payloads longer.
171
+ *
172
+ * The rest fire continuously (`superseded`, `age`) or at a point where the history is otherwise
173
+ * append-only (`turnEnd` — a new turn appends a user message, leaving the previous entry live),
174
+ * so batching them is exactly where the cache is won.
175
+ */
176
+ function marksDiscontinuity(kind: CondenseTrigger['kind']): boolean {
177
+ return kind === 'agentEnd' || kind === 'phaseEnd';
178
+ }
179
+
144
180
  /**
145
181
  * Collapse stale tool payloads (declared via `condenseWhen`) out of the
146
182
  * model-bound history. Pure over the `history` array — it emits new message
@@ -193,6 +229,20 @@ export function applyCondensation(
193
229
  // lookup. For each `superseded` key the LAST call in history order survives.
194
230
  // The name lookup lets a tool message (which carries only an id) name its tool
195
231
  // in stubs and events.
232
+ // Batch boundary: the collapse set is resolved as of this model-call and stays put
233
+ // until the next boundary, so the prefix is append-only in between. `batchCalls` of 1
234
+ // (the default) makes this `ctx.modelCall` — i.e. no batching.
235
+ const batchCalls = Math.max(1, Math.floor(ctx.batchCalls ?? 1));
236
+ const asOf =
237
+ batchCalls === 1 ? ctx.modelCall : Math.floor(ctx.modelCall / batchCalls) * batchCalls;
238
+ // A call made after the boundary is invisible to this pass in BOTH directions: it
239
+ // cannot supersede an older call (which would collapse the older payload early), and
240
+ // it cannot itself collapse (which would elide the very result just fetched). Unbatched,
241
+ // the boundary is the live clock and this gate is off entirely — a call registered on
242
+ // the current model-call is still eligible, as it always was.
243
+ const withinBatch = (entry: RegisteredCondensePolicy): boolean =>
244
+ batchCalls === 1 || entry.iteration <= asOf;
245
+
196
246
  const latestByKey = new Map<string, string>();
197
247
  const nameById = new Map<string, string>();
198
248
  for (const msg of history) {
@@ -200,7 +250,7 @@ export function applyCondensation(
200
250
  for (const tc of msg.toolCalls) {
201
251
  nameById.set(tc.id, tc.name);
202
252
  const entry = policies.get(tc.id);
203
- if (!entry) continue;
253
+ if (!entry || !withinBatch(entry)) continue;
204
254
  for (const t of triggersOf(entry)) {
205
255
  if (t.kind === 'superseded') latestByKey.set(t.by, tc.id);
206
256
  }
@@ -217,7 +267,7 @@ export function applyCondensation(
217
267
  // past the last allowed view: turns:1 → seen once, then gone. Monotonic
218
268
  // clock, so this holds across turn boundaries too.
219
269
  case 'age':
220
- return ctx.modelCall - entry.iteration > trig.turns;
270
+ return asOf - entry.iteration > trig.turns;
221
271
  case 'turnEnd':
222
272
  return ctx.turn > entry.turn;
223
273
  case 'agentEnd':
@@ -238,7 +288,10 @@ export function applyCondensation(
238
288
  toolCallId: string,
239
289
  entry: RegisteredCondensePolicy,
240
290
  ): CondenseTrigger | undefined =>
241
- triggersOf(entry).find((t) => triggerFires(toolCallId, entry, t));
291
+ triggersOf(entry).find(
292
+ (t) =>
293
+ (marksDiscontinuity(t.kind) || withinBatch(entry)) && triggerFires(toolCallId, entry, t),
294
+ );
242
295
 
243
296
  return history.map((msg) => {
244
297
  // Tool-call ARGS live on the assistant message.