@gajae-code/agent-core 0.4.5 → 0.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.0] - 2026-06-13
6
+
7
+ ### Fixed
8
+
9
+ - Fixed compaction cut-point selection when the newest retained context ends in an uncuttable tool result, so automatic compaction can keep the latest assistant/tool-result pair instead of falling back to a no-op cut.
10
+
11
+ ### Changed
12
+
13
+ - Optimization Suite v3 Lane 2 (context cost): compaction token estimates now use a shared per-entry cache (`estimateEntryTokens`) keyed by a boundary-lossless fingerprint of the exact estimator fragments, covering `estimateEntriesTokens`, the `findCutPoint` reverse walk, and pruning candidate scoring — repeated full-session estimate p95 −97%, token totals exactly equal to fresh estimates and never stale after prune mutation. Pruned bash/search/grep tool results now carry a one-line digest notice (exit code, match/file count, first error line; capped at 1.25× the generic notice cost) instead of a bare truncation notice, with savings computed from the exact notice string; reads and other tools keep the generic notice. `trimOpenAiCompactInput` is O(n) via per-item serialized lengths and a running character sum (5k-item trim −99.9%) and is now exported.
14
+ - Optimization Suite v3 Lane 3 (serialization): `cloneJson` in the append-only context now uses a typed JSON-semantic recursive clone instead of a `JSON.parse(JSON.stringify())` round-trip (−36% median on clone-heavy paths), with exact JSON.stringify byte parity including the toJSON holder-key protocol (single get, no re-dispatch on replacement values), function/symbol dropping, sparse arrays, Dates, and prototype-bearing objects; the helper is now exported.
15
+
5
16
  ## [0.4.5] - 2026-06-12
6
17
 
7
18
  ### Changed
@@ -80,6 +80,8 @@ export interface AgentOptions {
80
80
  * Use this when abort decisions must happen before buffered events continue flowing.
81
81
  */
82
82
  onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
83
+ /** Called for non-content tool-choice incapability stream events. */
84
+ onToolChoiceIncapability?: AgentLoopConfig["onToolChoiceIncapability"];
83
85
  /**
84
86
  * Called when GPT-5 Harmony protocol leakage is detected and mitigated.
85
87
  */
@@ -121,3 +121,4 @@ export declare class AppendOnlyContextManager {
121
121
  invalidate(): void;
122
122
  reset(context: AgentContext, options: BuildOptions): void;
123
123
  }
124
+ export declare function cloneJson<T>(value: T): T;
@@ -97,6 +97,8 @@ export declare function estimateMessageTokensHeuristic(message: AgentMessage): n
97
97
  * counterpart of the native `countTokens(fragments)` aggregate.
98
98
  */
99
99
  export declare function estimateTextTokensHeuristic(fragments: string | readonly string[]): number;
100
+ export declare function estimateEntryTokens(entry: SessionEntry): number;
101
+ export declare function estimateEntriesTokens(entries: SessionEntry[], startIndex: number, endIndex: number): number;
100
102
  /**
101
103
  * Find the user message (or bashExecution) that starts the turn containing the given entry index.
102
104
  * Returns -1 if no turn start found before the index.
@@ -20,6 +20,12 @@ export interface ModelChangeEntry extends SessionEntryBase {
20
20
  model: string;
21
21
  /** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
22
22
  role?: string;
23
+ /** Requested model before a runtime substitution/fallback, in "provider/modelId" format. */
24
+ previousModel?: string;
25
+ /** Machine-readable reason for runtime model substitution/fallback. */
26
+ reason?: string;
27
+ /** Effective thinking level when the change was recorded. */
28
+ thinkingLevel?: string | null;
23
29
  }
24
30
  export interface ServiceTierChangeEntry extends SessionEntryBase {
25
31
  type: "service_tier_change";
@@ -41,6 +41,8 @@ export interface RemoteCompactionResponse {
41
41
  export declare function shouldUseOpenAiRemoteCompaction(model: Model): boolean;
42
42
  export declare function getPreservedOpenAiRemoteCompactionData(preserveData: Record<string, unknown> | undefined): OpenAiRemoteCompactionPreserveData | undefined;
43
43
  export declare function withOpenAiRemoteCompactionPreserveData(preserveData: Record<string, unknown> | undefined, remoteCompaction: OpenAiRemoteCompactionPreserveData | undefined): Record<string, unknown> | undefined;
44
+ export declare function estimateOpenAiCompactInputTokens(input: Array<Record<string, unknown>>, instructions: string): number;
45
+ export declare function trimOpenAiCompactInput(input: Array<Record<string, unknown>>, contextWindow: number, instructions: string): Array<Record<string, unknown>>;
44
46
  /**
45
47
  * Build the OpenAI Responses-API native history array from LLM messages.
46
48
  *
@@ -154,6 +154,10 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
154
154
  * Callers may abort synchronously to stop consuming buffered provider events.
155
155
  */
156
156
  onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
157
+ /** Called for non-content tool-choice incapability stream events. */
158
+ onToolChoiceIncapability?: (event: Extract<AssistantMessageEvent, {
159
+ type: "toolChoiceIncapability";
160
+ }>) => void;
157
161
  /**
158
162
  * Called when GPT-5 Harmony protocol leakage is detected and mitigated.
159
163
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.4.5",
4
+ "version": "0.5.0",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@gajae-code/ai": "0.4.5",
39
- "@gajae-code/natives": "0.4.5",
40
- "@gajae-code/utils": "0.4.5",
38
+ "@gajae-code/ai": "0.5.0",
39
+ "@gajae-code/natives": "0.5.0",
40
+ "@gajae-code/utils": "0.5.0",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -815,6 +815,10 @@ async function streamAssistantResponse(
815
815
  stream.push({ type: "message_start", message: { ...partialMessage } });
816
816
  break;
817
817
 
818
+ case "toolChoiceIncapability":
819
+ config.onToolChoiceIncapability?.(event);
820
+ break;
821
+
818
822
  case "text_start":
819
823
  case "text_delta":
820
824
  case "text_end":
package/src/agent.ts CHANGED
@@ -151,6 +151,8 @@ export interface AgentOptions {
151
151
  * Use this when abort decisions must happen before buffered events continue flowing.
152
152
  */
153
153
  onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
154
+ /** Called for non-content tool-choice incapability stream events. */
155
+ onToolChoiceIncapability?: AgentLoopConfig["onToolChoiceIncapability"];
154
156
 
155
157
  /**
156
158
  * Called when GPT-5 Harmony protocol leakage is detected and mitigated.
@@ -309,6 +311,7 @@ export class Agent {
309
311
  #onResponse?: SimpleStreamOptions["onResponse"];
310
312
  #onSseEvent?: SimpleStreamOptions["onSseEvent"];
311
313
  #onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
314
+ #onToolChoiceIncapability?: AgentLoopConfig["onToolChoiceIncapability"];
312
315
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
313
316
  #onBeforeYield?: () => Promise<void> | void;
314
317
  #shouldPause?: AgentLoopConfig["shouldPause"];
@@ -373,6 +376,7 @@ export class Agent {
373
376
  this.#intentTracing = opts.intentTracing === true;
374
377
  this.#getToolChoice = opts.getToolChoice;
375
378
  this.#onAssistantMessageEvent = opts.onAssistantMessageEvent;
379
+ this.#onToolChoiceIncapability = opts.onToolChoiceIncapability;
376
380
  this.#onHarmonyLeak = opts.onHarmonyLeak;
377
381
  this.#shouldPause = opts.shouldPause;
378
382
  this.beforeToolCall = opts.beforeToolCall;
@@ -1222,6 +1226,12 @@ export class Agent {
1222
1226
  this.#onAssistantMessageEvent?.(message, event);
1223
1227
  }
1224
1228
  : undefined,
1229
+ onToolChoiceIncapability: this.#onToolChoiceIncapability
1230
+ ? event => {
1231
+ if (this.#activeRunId !== runId) return;
1232
+ this.#onToolChoiceIncapability?.(event);
1233
+ }
1234
+ : undefined,
1225
1235
  onHarmonyLeak: this.#onHarmonyLeak,
1226
1236
  getToolChoice,
1227
1237
  getReasoning: () => this.#state.thinkingLevel,
@@ -46,6 +46,9 @@ export interface BuildOptions {
46
46
  export class StablePrefix {
47
47
  #snapshot: StablePrefixSnapshot | null = null;
48
48
  #version = 0;
49
+ #sourceSystemPrompt: readonly string[] | null = null;
50
+ #sourceTools: AgentContext["tools"] | null = null;
51
+ #sourceIntentTracing: boolean | null = null;
49
52
 
50
53
  get fingerprint(): string {
51
54
  return this.#snapshot?.fingerprint ?? "<unbuilt>";
@@ -65,6 +68,9 @@ export class StablePrefix {
65
68
  const systemPrompt = cloneJson(snapshot.systemPrompt);
66
69
  const tools = normalizeImportedTools(snapshot.tools, options);
67
70
  const fingerprint = computeFingerprint(systemPrompt, tools, options);
71
+ this.#sourceSystemPrompt = null;
72
+ this.#sourceTools = null;
73
+ this.#sourceIntentTracing = null;
68
74
  if (fingerprint !== snapshot.fingerprint) {
69
75
  throw new Error(
70
76
  `StablePrefix.importSnapshot() fingerprint mismatch: expected ${fingerprint}, received ${snapshot.fingerprint}`,
@@ -79,11 +85,26 @@ export class StablePrefix {
79
85
  * Returns `true` if the prefix actually changed (cache miss imminent).
80
86
  */
81
87
  build(context: AgentContext, options: BuildOptions): boolean {
88
+ if (
89
+ this.#snapshot &&
90
+ this.#sourceSystemPrompt === context.systemPrompt &&
91
+ this.#sourceTools === context.tools &&
92
+ this.#sourceIntentTracing === options.intentTracing
93
+ ) {
94
+ const sourceFingerprint = takeSnapshot(context, options).fingerprint;
95
+ if (this.#snapshot.fingerprint === sourceFingerprint) return false;
96
+ }
82
97
  const snapshot = takeSnapshot(context, options);
83
98
  if (this.#snapshot && this.#snapshot.fingerprint === snapshot.fingerprint) {
99
+ this.#sourceSystemPrompt = context.systemPrompt;
100
+ this.#sourceTools = context.tools;
101
+ this.#sourceIntentTracing = options.intentTracing;
84
102
  return false;
85
103
  }
86
104
  this.#snapshot = snapshot;
105
+ this.#sourceSystemPrompt = context.systemPrompt;
106
+ this.#sourceTools = context.tools;
107
+ this.#sourceIntentTracing = options.intentTracing;
87
108
  this.#version++;
88
109
  return true;
89
110
  }
@@ -91,6 +112,9 @@ export class StablePrefix {
91
112
  /** Force rebuild on the next `build()` call. */
92
113
  invalidate(): void {
93
114
  this.#snapshot = null;
115
+ this.#sourceSystemPrompt = null;
116
+ this.#sourceTools = null;
117
+ this.#sourceIntentTracing = null;
94
118
  }
95
119
 
96
120
  /**
@@ -175,8 +199,8 @@ export class AppendOnlyContextManager {
175
199
  readonly log = new AppendOnlyLog();
176
200
  /** How many normalized messages were synced into the log as of the last sync. */
177
201
  #lastSyncCount = 0;
178
- /** Rolling digest of synced message content — detects in-place rewrites. */
179
- #syncedDigest = 0;
202
+ /** Fingerprint plus source bytes of synced message content — detects in-place rewrites with no hash-only equality. */
203
+ #syncedDigest = emptyMessageDigest();
180
204
  /** Number of provider-normalized messages that were seeded before child-local messages. */
181
205
  #seededPrefixCount = 0;
182
206
 
@@ -208,19 +232,22 @@ export class AppendOnlyContextManager {
208
232
  * (same length, changed content via a rolling digest).
209
233
  */
210
234
  syncMessages(normalizedMessages: any[]): void {
211
- const seededPrefix = this.#seededPrefixCount > 0 ? this.log.toMessages().slice(0, this.#seededPrefixCount) : [];
235
+ const seededPrefixLength = this.#seededPrefixCount;
212
236
  const includesSeedPrefix =
213
- seededPrefix.length > 0 &&
214
- normalizedMessages.length >= seededPrefix.length &&
215
- this.#computeDigest(normalizedMessages.slice(0, seededPrefix.length)) === this.#computeDigest(seededPrefix);
237
+ seededPrefixLength > 0 &&
238
+ normalizedMessages.length >= seededPrefixLength &&
239
+ this.#computeDigestRange(normalizedMessages, 0, seededPrefixLength).source ===
240
+ this.#computeDigestRange(this.log.entries(), 0, seededPrefixLength).source;
216
241
  const messagesToSync =
217
- seededPrefix.length > 0 && !includesSeedPrefix ? [...seededPrefix, ...normalizedMessages] : normalizedMessages;
242
+ seededPrefixLength > 0 && !includesSeedPrefix
243
+ ? [...this.log.entries().slice(0, seededPrefixLength), ...normalizedMessages]
244
+ : normalizedMessages;
218
245
 
219
246
  // Detect in-place rewrites of already-synced messages.
220
247
  if (
221
248
  this.#lastSyncCount > 0 &&
222
249
  this.#lastSyncCount <= messagesToSync.length &&
223
- this.#computeDigest(messagesToSync.slice(0, this.#lastSyncCount)) !== this.#syncedDigest
250
+ this.#computeDigestRange(messagesToSync, 0, this.#lastSyncCount).source !== this.#syncedDigest.source
224
251
  ) {
225
252
  if (this.#seededPrefixCount > 0) {
226
253
  throw new Error("AppendOnlyContextManager.syncMessages() seed prefix changed");
@@ -266,7 +293,7 @@ export class AppendOnlyContextManager {
266
293
  this.prefix.invalidate();
267
294
  this.log.clear();
268
295
  this.#lastSyncCount = 0;
269
- this.#syncedDigest = 0;
296
+ this.#syncedDigest = emptyMessageDigest();
270
297
  this.#seededPrefixCount = 0;
271
298
  }
272
299
 
@@ -274,7 +301,7 @@ export class AppendOnlyContextManager {
274
301
  resetSyncCursor(): void {
275
302
  this.log.clear();
276
303
  this.#lastSyncCount = 0;
277
- this.#syncedDigest = 0;
304
+ this.#syncedDigest = emptyMessageDigest();
278
305
  this.#seededPrefixCount = 0;
279
306
  }
280
307
 
@@ -294,36 +321,28 @@ export class AppendOnlyContextManager {
294
321
  this.prefix.invalidate();
295
322
  this.log.clear();
296
323
  this.#lastSyncCount = 0;
297
- this.#syncedDigest = 0;
324
+ this.#syncedDigest = emptyMessageDigest();
298
325
  this.#seededPrefixCount = 0;
299
326
  this.prefix.build(context, options);
300
327
  }
301
328
 
302
329
  /**
303
- * Deterministic digest over every field the provider may serialize — role,
304
- * content, tool calls (both `toolCalls` and OpenAI-wire `tool_calls`),
305
- * `tool_call_id`, `name`, `id`. Hashed with the same FNV-style rolling
306
- * accumulator so in-place rewrites of *any* of these fields are visible.
330
+ * Deterministic digest over the provider-visible message payload. The source
331
+ * string is kept and compared for equality so the hash is only a fast summary,
332
+ * never the authority for accepting append-only sync state.
307
333
  */
308
- #computeDigest(messages: readonly unknown[]): number {
309
- let hash = 0;
310
- for (let i = 0; i < messages.length; i++) {
311
- const msg = messages[i];
312
- if (!msg || typeof msg !== "object") continue;
313
- const m = msg as Record<string, unknown>;
314
- const payload = JSON.stringify({
315
- r: m.role ?? null,
316
- c: m.content ?? null,
317
- tc: m.toolCalls ?? m.tool_calls ?? null,
318
- tcid: m.tool_call_id ?? null,
319
- n: m.name ?? null,
320
- id: m.id ?? null,
321
- });
322
- for (let j = 0; j < payload.length; j++) {
323
- hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0;
324
- }
334
+ #computeDigest(messages: readonly unknown[]): MessageDigest {
335
+ return this.#computeDigestRange(messages, 0, messages.length);
336
+ }
337
+
338
+ #computeDigestRange(messages: readonly unknown[], start: number, end: number): MessageDigest {
339
+ let source = "[";
340
+ for (let i = start; i < end; i++) {
341
+ if (i > start) source += ",";
342
+ source += JSON.stringify(messages[i]) ?? "null";
325
343
  }
326
- return hash >>> 0;
344
+ source += "]";
345
+ return { hash: hashSource(source), source };
327
346
  }
328
347
  }
329
348
 
@@ -331,6 +350,27 @@ export class AppendOnlyContextManager {
331
350
  // Snapshot helpers
332
351
  // ---------------------------------------------------------------------------
333
352
 
353
+ type MessageDigest = {
354
+ hash: number | bigint;
355
+ source: string;
356
+ };
357
+
358
+ function emptyMessageDigest(): MessageDigest {
359
+ return { hash: hashSource("[]"), source: "[]" };
360
+ }
361
+
362
+ function hashSource(source: string): number | bigint {
363
+ return typeof Bun !== "undefined" ? Bun.hash(source) : hashString32(source);
364
+ }
365
+
366
+ function hashString32(value: string): number {
367
+ let hash = 0;
368
+ for (let i = 0; i < value.length; i++) {
369
+ hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
370
+ }
371
+ return hash >>> 0;
372
+ }
373
+
334
374
  function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
335
375
  const systemPrompt = [...context.systemPrompt];
336
376
  const tools = normalizeTools(context.tools, options.intentTracing) ?? [];
@@ -347,8 +387,42 @@ function normalizeImportedTools(tools: readonly Tool[], options: BuildOptions):
347
387
  return cloneJson(normalizedTools);
348
388
  }
349
389
 
350
- function cloneJson<T>(value: T): T {
351
- return JSON.parse(JSON.stringify(value)) as T;
390
+ export function cloneJson<T>(value: T): T {
391
+ return cloneJsonValue(value) as T;
392
+ }
393
+
394
+ function cloneJsonValue(value: unknown, key = "", applyToJson = true): unknown {
395
+ if (value === null) return null;
396
+ const type = typeof value;
397
+ if (type === "number") return Number.isFinite(value) ? value : null;
398
+ // JSON.stringify drops function/symbol/undefined values (object props
399
+ // omitted, array elements become null via the array walk below).
400
+ if (type === "undefined" || type === "function" || type === "symbol") return undefined;
401
+ if (type !== "object") return value;
402
+ if (applyToJson) {
403
+ // JSON.stringify performs a single Get of `toJSON` per holder/key and
404
+ // serializes the returned replacement WITHOUT re-dispatching the
405
+ // replacement's own toJSON at the same level (nested properties still
406
+ // dispatch normally). Mirror that exactly to keep byte parity.
407
+ const toJSON = (value as { toJSON?: unknown }).toJSON;
408
+ if (typeof toJSON === "function") {
409
+ return cloneJsonValue(toJSON.call(value, key), key, false);
410
+ }
411
+ }
412
+ if (Array.isArray(value)) {
413
+ const cloned: unknown[] = new Array(value.length);
414
+ for (let i = 0; i < value.length; i++) {
415
+ const item = Object.hasOwn(value, i) ? cloneJsonValue(value[i], String(i)) : undefined;
416
+ cloned[i] = item === undefined ? null : item;
417
+ }
418
+ return cloned;
419
+ }
420
+ const cloned: Record<string, unknown> = {};
421
+ for (const key of Object.keys(value as object)) {
422
+ const clonedValue = cloneJsonValue((value as Record<string, unknown>)[key], key);
423
+ if (clonedValue !== undefined) cloned[key] = clonedValue;
424
+ }
425
+ return cloned;
352
426
  }
353
427
 
354
428
  function computeFingerprint(systemPrompt: string[], tools: Tool[], options: BuildOptions): string {
@@ -287,6 +287,10 @@ function nativeCountTokens(fragments: string[]): number {
287
287
  return cachedNativeCountTokens(fragments);
288
288
  }
289
289
 
290
+ function countCollectedMessageFragments(collected: { fragments: string[]; extra: number }): number {
291
+ return nativeCountTokens(collected.fragments) + collected.extra;
292
+ }
293
+
290
294
  /**
291
295
  * Estimate token count for a message using the native o200k tokenizer.
292
296
  * Exact for o200k only; an approximation for Anthropic/other model families
@@ -299,9 +303,7 @@ function nativeCountTokens(fragments: string[]): number {
299
303
  * {@link estimateMessageTokensHeuristic}.
300
304
  */
301
305
  export function countMessageTokensNativeO200k(message: AgentMessage): number {
302
- const { fragments, extra } = collectMessageFragments(message);
303
- if (fragments.length === 0) return extra;
304
- return extra + nativeCountTokens(fragments);
306
+ return countCollectedMessageFragments(collectMessageFragments(message));
305
307
  }
306
308
 
307
309
  /**
@@ -413,13 +415,39 @@ function collectMessageFragments(message: AgentMessage): { fragments: string[];
413
415
  return { fragments, extra };
414
416
  }
415
417
 
416
- function estimateEntriesTokens(entries: SessionEntry[], startIndex: number, endIndex: number): number {
418
+ function entryTokenFingerprint(
419
+ entry: SessionEntry,
420
+ message: AgentMessage,
421
+ collected: { fragments: string[]; extra: number },
422
+ ): string {
423
+ const maybePruned = message as { prunedAt?: unknown };
424
+ let fingerprint = `${entry.type.length}:${entry.type}${(entry.id ?? "").length}:${entry.id ?? ""}${message.role.length}:${message.role}${String(collected.extra).length}:${String(collected.extra)}${collected.fragments.length}:`;
425
+ for (const fragment of collected.fragments) fingerprint += `${fragment.length}:${fragment}`;
426
+ if (maybePruned.prunedAt !== undefined) {
427
+ const prunedAt = String(maybePruned.prunedAt);
428
+ fingerprint += `prunedAt${prunedAt.length}:${prunedAt}`;
429
+ }
430
+ return fingerprint;
431
+ }
432
+
433
+ const entryTokenCache = new WeakMap<SessionEntry, { fingerprint: string; tokens: number }>();
434
+
435
+ export function estimateEntryTokens(entry: SessionEntry): number {
436
+ const msg = getMessageFromEntry(entry);
437
+ if (!msg) return 0;
438
+ const collected = collectMessageFragments(msg);
439
+ const fingerprint = entryTokenFingerprint(entry, msg, collected);
440
+ const cached = entryTokenCache.get(entry);
441
+ if (cached?.fingerprint === fingerprint) return cached.tokens;
442
+ const tokens = countCollectedMessageFragments(collected);
443
+ entryTokenCache.set(entry, { fingerprint, tokens });
444
+ return tokens;
445
+ }
446
+
447
+ export function estimateEntriesTokens(entries: SessionEntry[], startIndex: number, endIndex: number): number {
417
448
  let total = 0;
418
449
  for (let i = startIndex; i < endIndex; i++) {
419
- const msg = getMessageFromEntry(entries[i]);
420
- if (msg) {
421
- total += estimateTokens(msg);
422
- }
450
+ total += estimateEntryTokens(entries[i]);
423
451
  }
424
452
  return total;
425
453
  }
@@ -536,18 +564,23 @@ export function findCutPoint(
536
564
  if (entry.type !== "message") continue;
537
565
 
538
566
  // Estimate this message's size
539
- const messageTokens = estimateTokens(entry.message);
567
+ const messageTokens = estimateEntryTokens(entry);
540
568
  accumulatedTokens += messageTokens;
541
569
 
542
570
  // Check if we've exceeded the budget
543
571
  if (accumulatedTokens >= keepRecentTokens) {
544
572
  // Find the closest valid cut point at or after this entry
573
+ let foundCutPoint = false;
545
574
  for (let c = 0; c < cutPoints.length; c++) {
546
575
  if (cutPoints[c] >= i) {
547
576
  cutIndex = cutPoints[c];
577
+ foundCutPoint = true;
548
578
  break;
549
579
  }
550
580
  }
581
+ if (!foundCutPoint) {
582
+ cutIndex = cutPoints[cutPoints.length - 1];
583
+ }
551
584
  break;
552
585
  }
553
586
  }
@@ -24,6 +24,12 @@ export interface ModelChangeEntry extends SessionEntryBase {
24
24
  model: string;
25
25
  /** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
26
26
  role?: string;
27
+ /** Requested model before a runtime substitution/fallback, in "provider/modelId" format. */
28
+ previousModel?: string;
29
+ /** Machine-readable reason for runtime model substitution/fallback. */
30
+ reason?: string;
31
+ /** Effective thinking level when the change was recorded. */
32
+ thinkingLevel?: string | null;
27
33
  }
28
34
 
29
35
  export interface ServiceTierChangeEntry extends SessionEntryBase {
@@ -154,7 +154,7 @@ export function withOpenAiRemoteCompactionPreserveData(
154
154
  // Input/output filtering for OpenAI compact endpoint
155
155
  // ============================================================================
156
156
 
157
- function estimateOpenAiCompactInputTokens(input: Array<Record<string, unknown>>, instructions: string): number {
157
+ export function estimateOpenAiCompactInputTokens(input: Array<Record<string, unknown>>, instructions: string): number {
158
158
  let chars = instructions.length;
159
159
  for (const item of input) {
160
160
  chars += JSON.stringify(item).length;
@@ -200,22 +200,31 @@ function shouldKeepOpenAiCompactOutputItem(item: Record<string, unknown>): boole
200
200
  return shouldKeepOpenAiCompactOutputUserMessage(item);
201
201
  }
202
202
 
203
- function trimOpenAiCompactInput(
203
+ export function trimOpenAiCompactInput(
204
204
  input: Array<Record<string, unknown>>,
205
205
  contextWindow: number,
206
206
  instructions: string,
207
207
  ): Array<Record<string, unknown>> {
208
+ const itemLengths = input.map(item => JSON.stringify(item).length);
209
+ let chars = instructions.length;
210
+ for (const length of itemLengths) chars += length;
211
+
212
+ function removeAt(index: number): void {
213
+ chars -= itemLengths[index] ?? 0;
214
+ trimmed.splice(index, 1);
215
+ itemLengths.splice(index, 1);
216
+ }
208
217
  const trimmed = [...input];
209
- while (trimmed.length > 0 && estimateOpenAiCompactInputTokens(trimmed, instructions) > contextWindow) {
218
+ while (trimmed.length > 0 && Math.ceil(chars / 4) > contextWindow) {
210
219
  const last = trimmed[trimmed.length - 1];
211
220
  if (last?.type === "function_call_output" || last?.type === "custom_tool_call_output") {
212
221
  const callId = typeof last.call_id === "string" ? last.call_id : undefined;
213
222
  const callType = last.type === "custom_tool_call_output" ? "custom_tool_call" : "function_call";
214
- trimmed.pop();
223
+ removeAt(trimmed.length - 1);
215
224
  if (callId) {
216
225
  const matchingCallIndex = trimmed.findLastIndex(item => item.type === callType && item.call_id === callId);
217
226
  if (matchingCallIndex >= 0) {
218
- trimmed.splice(matchingCallIndex, 1);
227
+ removeAt(matchingCallIndex);
219
228
  }
220
229
  }
221
230
  continue;
@@ -223,7 +232,7 @@ function trimOpenAiCompactInput(
223
232
  if (!last || !shouldTrimOpenAiCompactInputItem(last)) {
224
233
  break;
225
234
  }
226
- trimmed.pop();
235
+ removeAt(trimmed.length - 1);
227
236
  }
228
237
  return trimmed;
229
238
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { ToolCall, ToolResultMessage } from "@gajae-code/ai";
12
12
  import type { AgentMessage } from "../types";
13
- import { estimateTokens } from "./compaction";
13
+ import { estimateEntryTokens } from "./compaction";
14
14
  import type { SessionEntry, SessionMessageEntry } from "./entries";
15
15
 
16
16
  export interface PruneConfig {
@@ -47,10 +47,73 @@ export interface PruneResult {
47
47
  prunedEntries: SessionMessageEntry[];
48
48
  }
49
49
 
50
- function createPrunedNotice(tokens: number): string {
50
+ const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25;
51
+
52
+ function createGenericPrunedNotice(tokens: number): string {
51
53
  return `[Output truncated - ${tokens} tokens]`;
52
54
  }
53
55
 
56
+ function firstTextContent(message: ToolResultMessage): string {
57
+ if (typeof message.content === "string") return message.content;
58
+ const block = message.content.find(part => part.type === "text");
59
+ return block?.type === "text" ? block.text : "";
60
+ }
61
+
62
+ function firstErrorLine(text: string): string | undefined {
63
+ return text
64
+ .split(/\r?\n/)
65
+ .find(line => /error|failed|exception|panic/i.test(line))
66
+ ?.trim();
67
+ }
68
+
69
+ function truncateField(value: string, maxLength: number): string {
70
+ if (value.length <= maxLength) return value;
71
+ if (maxLength <= 1) return "…";
72
+ return `${value.slice(0, maxLength - 1)}…`;
73
+ }
74
+
75
+ function resultDigest(message: ToolResultMessage): string | undefined {
76
+ const toolName = message.toolName.toLowerCase();
77
+ const text = firstTextContent(message);
78
+ if (toolName === "bash") {
79
+ const details = message as { details?: { exitCode?: unknown } };
80
+ const exitCode =
81
+ typeof details.details?.exitCode === "number" ? details.details.exitCode : message.isError ? 1 : 0;
82
+ const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
83
+ const error = firstErrorLine(text);
84
+ return [`exit=${exitCode}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
85
+ .filter((part): part is string => part !== undefined)
86
+ .join("; ");
87
+ }
88
+ if (toolName === "search" || toolName === "grep") {
89
+ const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
90
+ const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
91
+ const error = firstErrorLine(text);
92
+ return (
93
+ [
94
+ match ? `matches=${match[1]}` : undefined,
95
+ files ? `files=${files[1]}` : undefined,
96
+ error ? `error=${error}` : undefined,
97
+ ]
98
+ .filter((part): part is string => part !== undefined)
99
+ .join("; ") || "search digest unavailable"
100
+ );
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ function createPrunedNotice(tokens: number, message?: ToolResultMessage): string {
106
+ const generic = createGenericPrunedNotice(tokens);
107
+ const digest = message ? resultDigest(message) : undefined;
108
+ if (!digest) return generic;
109
+ const genericTokens = Math.ceil(generic.length / 4);
110
+ const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
111
+ const prefix = `[Output truncated - ${tokens} tokens; `;
112
+ const suffix = "]";
113
+ const maxChars = Math.max(0, maxTokens * 4 - prefix.length - suffix.length);
114
+ return `${prefix}${truncateField(digest, maxChars)}${suffix}`;
115
+ }
116
+
54
117
  function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
55
118
  if (entry.type !== "message") return undefined;
56
119
  const message = entry.message as AgentMessage;
@@ -58,8 +121,8 @@ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefine
58
121
  return message as ToolResultMessage;
59
122
  }
60
123
 
61
- function estimatePrunedSavings(tokens: number): number {
62
- const noticeTokens = Math.ceil(createPrunedNotice(tokens).length / 4);
124
+ function estimatePrunedSavings(tokens: number, notice: string): number {
125
+ const noticeTokens = Math.ceil(notice.length / 4);
63
126
  return Math.max(0, tokens - noticeTokens);
64
127
  }
65
128
 
@@ -306,14 +369,14 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
306
369
 
307
370
  const { staleResultIndices } = buildStalenessIndex(entries);
308
371
  const staleOverridable = new Set(config.staleOverridableTools ?? []);
309
- const candidates: Array<{ entry: SessionMessageEntry; tokens: number }> = [];
372
+ const candidates: Array<{ entry: SessionMessageEntry; tokens: number; notice: string; savings: number }> = [];
310
373
 
311
374
  for (let i = entries.length - 1; i >= 0; i--) {
312
375
  const entry = entries[i];
313
376
  const message = getToolResultMessage(entry);
314
377
  if (!message) continue;
315
378
 
316
- const tokens = estimateTokens(message as AgentMessage);
379
+ const tokens = estimateEntryTokens(entry);
317
380
  const isStale = staleResultIndices.has(i);
318
381
  // Staleness waives protected-tool immunity for overridable tools
319
382
  // (e.g. a superseded `read`); the most recent result per target is
@@ -336,12 +399,18 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
336
399
  continue;
337
400
  }
338
401
 
339
- candidates.push({ entry: entry as SessionMessageEntry, tokens });
402
+ const notice = createPrunedNotice(tokens, message);
403
+ candidates.push({
404
+ entry: entry as SessionMessageEntry,
405
+ tokens,
406
+ notice,
407
+ savings: estimatePrunedSavings(tokens, notice),
408
+ });
340
409
  accumulatedTokens += tokens;
341
410
  }
342
411
 
343
412
  for (const candidate of candidates) {
344
- tokensSaved += estimatePrunedSavings(candidate.tokens);
413
+ tokensSaved += candidate.savings;
345
414
  }
346
415
 
347
416
  if (tokensSaved < config.minimumSavings || candidates.length === 0) {
@@ -352,7 +421,7 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
352
421
  const prunedEntries: SessionMessageEntry[] = [];
353
422
  for (const candidate of candidates) {
354
423
  const message = candidate.entry.message as ToolResultMessage;
355
- message.content = [{ type: "text", text: createPrunedNotice(candidate.tokens) }];
424
+ message.content = [{ type: "text", text: candidate.notice }];
356
425
  message.prunedAt = prunedAt;
357
426
  prunedEntries.push(candidate.entry);
358
427
  prunedCount++;
package/src/types.ts CHANGED
@@ -189,6 +189,9 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
189
189
  */
190
190
  onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
191
191
 
192
+ /** Called for non-content tool-choice incapability stream events. */
193
+ onToolChoiceIncapability?: (event: Extract<AssistantMessageEvent, { type: "toolChoiceIncapability" }>) => void;
194
+
192
195
  /**
193
196
  * Called when GPT-5 Harmony protocol leakage is detected and mitigated.
194
197
  */