@gajae-code/agent-core 0.5.2 → 0.5.3

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.3] - 2026-06-16
6
+
7
+ ### Fixed
8
+
9
+ - Bounded agent context growth, compaction, and token accounting for long-running sessions: `appendMessage` pushes in place instead of rebuilding the array; the append-only context keeps rolling per-message hashes instead of rescanning the full digest; an emergency-compaction floor that cannot be disabled now surfaces its reason; `getSessionStats` is single-pass; and `nativeCountTokens` skips the synchronous ~39 MB BPE tokenizer above a 2 MiB input cap, falling back to the cheap heuristic (#717).
10
+
5
11
  ## [0.5.2] - 2026-06-15
6
12
 
7
13
  ### Fixed
@@ -66,6 +66,37 @@ export declare function effectiveReserveTokens(contextWindow: number, settings:
66
66
  * the safe input budget so prompt + reserved output cannot exceed the total window.
67
67
  */
68
68
  export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): boolean;
69
+ /** Reason a compaction was triggered. `token` is the normal user-configurable path; the rest are emergency floors. */
70
+ export type CompactionTriggerReason = "token" | "heap" | "providerBytes" | "messageCount" | "imageBytes";
71
+ /** A point-in-time resource sample. Supplied by an injectable sampler so tests never read real RSS. */
72
+ export interface EmergencyCompactionSample {
73
+ /** Resident heap bytes (e.g. process.memoryUsage().heapUsed). */
74
+ heapUsedBytes: number;
75
+ /** Approximate serialized provider-context bytes. */
76
+ providerBytes: number;
77
+ /** Provider-visible message count. */
78
+ messageCount: number;
79
+ /** Approximate inline image bytes in the provider context. */
80
+ imageBytes: number;
81
+ }
82
+ export interface EmergencyCompactionLimits {
83
+ heapUsedBytes: number;
84
+ providerBytes: number;
85
+ messageCount: number;
86
+ imageBytes: number;
87
+ }
88
+ /**
89
+ * Non-disableable emergency floors. These sit well above normal usage and exist so a
90
+ * long session on weak hardware compacts before OOM even when token-based compaction is
91
+ * disabled or its threshold is set too high. They are NOT user-tunable down to zero.
92
+ */
93
+ export declare const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits;
94
+ /**
95
+ * Returns the first emergency limit exceeded (heap > providerBytes > imageBytes > messageCount),
96
+ * or null when none is. Pure and sampler-injected; the caller routes the result through the
97
+ * normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
98
+ */
99
+ export declare function emergencyCompactionReason(sample: EmergencyCompactionSample, limits?: EmergencyCompactionLimits): CompactionTriggerReason | null;
69
100
  export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): number;
70
101
  /**
71
102
  * Estimate token count for a message using the native o200k tokenizer.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.5.2",
4
+ "version": "0.5.3",
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.5.2",
39
- "@gajae-code/natives": "0.5.2",
40
- "@gajae-code/utils": "0.5.2",
38
+ "@gajae-code/ai": "0.5.3",
39
+ "@gajae-code/natives": "0.5.3",
40
+ "@gajae-code/utils": "0.5.3",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
package/src/agent.ts CHANGED
@@ -827,7 +827,10 @@ export class Agent {
827
827
  }
828
828
 
829
829
  appendMessage(m: AgentMessage) {
830
- this.#state.messages = [...this.#state.messages, m];
830
+ // In-place push (not [...messages, m]): appending M messages over a session of
831
+ // N is O(N+M), not O(M*N). Consumers read state.messages fresh; run() snapshots
832
+ // via slice() at the API boundary, so no caller relies on per-append array identity.
833
+ this.#state.messages.push(m);
831
834
  }
832
835
 
833
836
  popMessage(): AgentMessage | undefined {
@@ -199,8 +199,8 @@ export class AppendOnlyContextManager {
199
199
  readonly log = new AppendOnlyLog();
200
200
  /** How many normalized messages were synced into the log as of the last sync. */
201
201
  #lastSyncCount = 0;
202
- /** Fingerprint plus source bytes of synced message content — detects in-place rewrites with no hash-only equality. */
203
- #syncedDigest = emptyMessageDigest();
202
+ /** Per-synced-message content hashes (rolling digest). Detects in-place rewrites without retaining a full serialized-history string. */
203
+ #syncedHashes: (number | bigint)[] = [];
204
204
  /** Number of provider-normalized messages that were seeded before child-local messages. */
205
205
  #seededPrefixCount = 0;
206
206
 
@@ -236,35 +236,41 @@ export class AppendOnlyContextManager {
236
236
  const includesSeedPrefix =
237
237
  seededPrefixLength > 0 &&
238
238
  normalizedMessages.length >= seededPrefixLength &&
239
- this.#computeDigestRange(normalizedMessages, 0, seededPrefixLength).source ===
240
- this.#computeDigestRange(this.log.entries(), 0, seededPrefixLength).source;
239
+ this.#rangeHashesEqual(normalizedMessages, this.log.entries(), seededPrefixLength);
241
240
  const messagesToSync =
242
241
  seededPrefixLength > 0 && !includesSeedPrefix
243
242
  ? [...this.log.entries().slice(0, seededPrefixLength), ...normalizedMessages]
244
243
  : normalizedMessages;
245
244
 
246
- // Detect in-place rewrites of already-synced messages.
245
+ // Detect in-place rewrites of already-synced messages via per-message content
246
+ // hashes (no retained full serialized-history string; F5).
247
247
  if (
248
248
  this.#lastSyncCount > 0 &&
249
249
  this.#lastSyncCount <= messagesToSync.length &&
250
- this.#computeDigestRange(messagesToSync, 0, this.#lastSyncCount).source !== this.#syncedDigest.source
250
+ this.#prefixChanged(messagesToSync, this.#lastSyncCount)
251
251
  ) {
252
252
  if (this.#seededPrefixCount > 0) {
253
- throw new Error("AppendOnlyContextManager.syncMessages() seed prefix changed");
253
+ // F9: a seeded fork whose inherited prefix changed (e.g. after compaction)
254
+ // rebases onto the new provider context instead of throwing.
255
+ this.#rebaseToBaseline(normalizedMessages);
256
+ return;
254
257
  }
255
258
  this.log.clear();
256
259
  this.#lastSyncCount = 0;
260
+ this.#syncedHashes = [];
257
261
  }
258
262
 
259
- // Compaction — array shrunk. Seeded forks preserve the inherited prefix
260
- // and append child-local deltas, so a shorter child message array is not a
261
- // compaction signal while a seed prefix is active.
263
+ // Compaction — array shrunk. Seeded forks preserve the inherited prefix and
264
+ // append child-local deltas, so a shorter child array is not a compaction signal
265
+ // while a seed prefix is active; a genuine seeded compaction rebases (F9).
262
266
  if (messagesToSync.length < this.#lastSyncCount) {
263
267
  if (this.#seededPrefixCount > 0) {
264
- throw new Error("AppendOnlyContextManager.syncMessages() cannot compact a seeded fork without reset");
268
+ this.#rebaseToBaseline(normalizedMessages);
269
+ return;
265
270
  }
266
271
  this.log.clear();
267
272
  this.#lastSyncCount = 0;
273
+ this.#syncedHashes = [];
268
274
  }
269
275
 
270
276
  const newMsgs = messagesToSync.slice(this.#lastSyncCount);
@@ -273,7 +279,7 @@ export class AppendOnlyContextManager {
273
279
  }
274
280
 
275
281
  this.#lastSyncCount = messagesToSync.length;
276
- this.#syncedDigest = this.#computeDigest(messagesToSync);
282
+ this.#syncedHashes = this.#hashRange(messagesToSync, 0, messagesToSync.length);
277
283
  }
278
284
 
279
285
  seedNormalizedMessages(messages: readonly Message[], options?: { reset?: boolean }): void {
@@ -284,7 +290,7 @@ export class AppendOnlyContextManager {
284
290
  this.log.clear();
285
291
  this.log.extend(clonedMessages);
286
292
  this.#lastSyncCount = clonedMessages.length;
287
- this.#syncedDigest = this.#computeDigest(clonedMessages);
293
+ this.#syncedHashes = this.#hashRange(clonedMessages, 0, clonedMessages.length);
288
294
  this.#seededPrefixCount = clonedMessages.length;
289
295
  }
290
296
 
@@ -293,7 +299,7 @@ export class AppendOnlyContextManager {
293
299
  this.prefix.invalidate();
294
300
  this.log.clear();
295
301
  this.#lastSyncCount = 0;
296
- this.#syncedDigest = emptyMessageDigest();
302
+ this.#syncedHashes = [];
297
303
  this.#seededPrefixCount = 0;
298
304
  }
299
305
 
@@ -301,7 +307,7 @@ export class AppendOnlyContextManager {
301
307
  resetSyncCursor(): void {
302
308
  this.log.clear();
303
309
  this.#lastSyncCount = 0;
304
- this.#syncedDigest = emptyMessageDigest();
310
+ this.#syncedHashes = [];
305
311
  this.#seededPrefixCount = 0;
306
312
  }
307
313
 
@@ -321,28 +327,45 @@ export class AppendOnlyContextManager {
321
327
  this.prefix.invalidate();
322
328
  this.log.clear();
323
329
  this.#lastSyncCount = 0;
324
- this.#syncedDigest = emptyMessageDigest();
330
+ this.#syncedHashes = [];
325
331
  this.#seededPrefixCount = 0;
326
332
  this.prefix.build(context, options);
327
333
  }
328
334
 
329
- /**
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.
333
- */
334
- #computeDigest(messages: readonly unknown[]): MessageDigest {
335
- return this.#computeDigestRange(messages, 0, messages.length);
335
+ #hashMessage(message: unknown): number | bigint {
336
+ return hashSource(JSON.stringify(message) ?? "null");
337
+ }
338
+
339
+ #hashRange(messages: readonly unknown[], start: number, end: number): (number | bigint)[] {
340
+ const out: (number | bigint)[] = [];
341
+ for (let i = start; i < end; i++) out.push(this.#hashMessage(messages[i]));
342
+ return out;
343
+ }
344
+
345
+ /** True when the first `count` messages of `a` and `b` are content-equal by per-message hash. */
346
+ #rangeHashesEqual(a: readonly unknown[], b: readonly unknown[], count: number): boolean {
347
+ for (let i = 0; i < count; i++) {
348
+ if (this.#hashMessage(a[i]) !== this.#hashMessage(b[i])) return false;
349
+ }
350
+ return true;
336
351
  }
337
352
 
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";
353
+ /** True when any of the first `count` already-synced messages changed content (in-place rewrite). */
354
+ #prefixChanged(messages: readonly unknown[], count: number): boolean {
355
+ if (count > this.#syncedHashes.length) return false;
356
+ for (let i = 0; i < count; i++) {
357
+ if (this.#hashMessage(messages[i]) !== this.#syncedHashes[i]) return true;
343
358
  }
344
- source += "]";
345
- return { hash: hashSource(source), source };
359
+ return false;
360
+ }
361
+
362
+ /** F9: reset the seeded log to a new provider-visible baseline (seeded compaction/rebase). */
363
+ #rebaseToBaseline(messages: readonly unknown[]): void {
364
+ this.log.clear();
365
+ this.log.extend([...messages]);
366
+ this.#lastSyncCount = messages.length;
367
+ this.#seededPrefixCount = 0;
368
+ this.#syncedHashes = this.#hashRange(messages, 0, messages.length);
346
369
  }
347
370
  }
348
371
 
@@ -350,15 +373,6 @@ export class AppendOnlyContextManager {
350
373
  // Snapshot helpers
351
374
  // ---------------------------------------------------------------------------
352
375
 
353
- type MessageDigest = {
354
- hash: number | bigint;
355
- source: string;
356
- };
357
-
358
- function emptyMessageDigest(): MessageDigest {
359
- return { hash: hashSource("[]"), source: "[]" };
360
- }
361
-
362
376
  function hashSource(source: string): number | bigint {
363
377
  return typeof Bun !== "undefined" ? Bun.hash(source) : hashString32(source);
364
378
  }
@@ -236,6 +236,56 @@ export function shouldCompact(
236
236
  return contextTokens > thresholdTokens;
237
237
  }
238
238
 
239
+ /** Reason a compaction was triggered. `token` is the normal user-configurable path; the rest are emergency floors. */
240
+ export type CompactionTriggerReason = "token" | "heap" | "providerBytes" | "messageCount" | "imageBytes";
241
+
242
+ /** A point-in-time resource sample. Supplied by an injectable sampler so tests never read real RSS. */
243
+ export interface EmergencyCompactionSample {
244
+ /** Resident heap bytes (e.g. process.memoryUsage().heapUsed). */
245
+ heapUsedBytes: number;
246
+ /** Approximate serialized provider-context bytes. */
247
+ providerBytes: number;
248
+ /** Provider-visible message count. */
249
+ messageCount: number;
250
+ /** Approximate inline image bytes in the provider context. */
251
+ imageBytes: number;
252
+ }
253
+
254
+ export interface EmergencyCompactionLimits {
255
+ heapUsedBytes: number;
256
+ providerBytes: number;
257
+ messageCount: number;
258
+ imageBytes: number;
259
+ }
260
+
261
+ /**
262
+ * Non-disableable emergency floors. These sit well above normal usage and exist so a
263
+ * long session on weak hardware compacts before OOM even when token-based compaction is
264
+ * disabled or its threshold is set too high. They are NOT user-tunable down to zero.
265
+ */
266
+ export const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits = {
267
+ heapUsedBytes: 1_536 * 1024 * 1024, // 1.5 GiB resident heap
268
+ providerBytes: 24 * 1024 * 1024, // 24 MiB serialized provider context
269
+ messageCount: 4000,
270
+ imageBytes: 64 * 1024 * 1024, // 64 MiB inline image bytes
271
+ };
272
+
273
+ /**
274
+ * Returns the first emergency limit exceeded (heap > providerBytes > imageBytes > messageCount),
275
+ * or null when none is. Pure and sampler-injected; the caller routes the result through the
276
+ * normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
277
+ */
278
+ export function emergencyCompactionReason(
279
+ sample: EmergencyCompactionSample,
280
+ limits: EmergencyCompactionLimits = DEFAULT_EMERGENCY_COMPACTION_LIMITS,
281
+ ): CompactionTriggerReason | null {
282
+ if (sample.heapUsedBytes > limits.heapUsedBytes) return "heap";
283
+ if (sample.providerBytes > limits.providerBytes) return "providerBytes";
284
+ if (sample.imageBytes > limits.imageBytes) return "imageBytes";
285
+ if (sample.messageCount > limits.messageCount) return "messageCount";
286
+ return null;
287
+ }
288
+
239
289
  export function resolveThresholdTokens(
240
290
  contextWindow: number,
241
291
  settings: CompactionSettings,
@@ -301,7 +351,18 @@ function nativeTokenizerEntrypoint(): string {
301
351
  return isCompiledBinary() ? COMPILED_NATIVE_TOKENIZER_ENTRYPOINT : SOURCE_NATIVE_TOKENIZER_ENTRYPOINT;
302
352
  }
303
353
 
354
+ /** Max total fragment chars sent to the synchronous native tokenizer (F22). */
355
+ const MAX_NATIVE_TOKENIZE_CHARS = 2 * 1024 * 1024;
356
+
304
357
  function nativeCountTokens(fragments: string[]): number {
358
+ let totalChars = 0;
359
+ for (const fragment of fragments) totalChars += fragment.length;
360
+ if (totalChars > MAX_NATIVE_TOKENIZE_CHARS) {
361
+ // F22: skip the synchronous native BPE tokenizer (materializes a ~39MB table and is
362
+ // O(text)) on pathologically large inputs; the cheap chars/token heuristic is more
363
+ // than accurate enough for size/budget decisions and never blocks the event loop.
364
+ return estimateTextTokensHeuristic(fragments);
365
+ }
305
366
  if (!cachedNativeCountTokens) {
306
367
  const natives = requireFromCompaction(nativeTokenizerEntrypoint()) as NativeTokenizerModule;
307
368
  cachedNativeCountTokens = natives.countTokens;