@gajae-code/agent-core 0.5.1 → 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,18 @@
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
+
11
+ ## [0.5.2] - 2026-06-15
12
+
13
+ ### Fixed
14
+
15
+ - Fixed compaction token estimation in Bun standalone binaries by loading the native tokenizer through the sibling native entrypoint instead of package-name dynamic resolution.
16
+
5
17
  ## [0.5.1] - 2026-06-14
6
18
 
7
19
  - Version aligned with the 0.5.1 monorepo release; no functional changes in this package.
@@ -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.1",
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.1",
39
- "@gajae-code/natives": "0.5.1",
40
- "@gajae-code/utils": "0.5.1",
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
  }
@@ -5,6 +5,7 @@
5
5
  * and after compaction the session is reloaded.
6
6
  */
7
7
 
8
+ import { createRequire } from "node:module";
8
9
  import {
9
10
  type AssistantMessage,
10
11
  Effort,
@@ -13,7 +14,7 @@ import {
13
14
  type Model,
14
15
  type Usage,
15
16
  } from "@gajae-code/ai";
16
- import { logger, prompt } from "@gajae-code/utils";
17
+ import { isCompiledBinary, logger, prompt } from "@gajae-code/utils";
17
18
  import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
18
19
  import type { AgentMessage, AgentTool } from "../types";
19
20
  import type { CompactionEntry, SessionEntry } from "./entries";
@@ -235,6 +236,56 @@ export function shouldCompact(
235
236
  return contextTokens > thresholdTokens;
236
237
  }
237
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
+
238
289
  export function resolveThresholdTokens(
239
290
  contextWindow: number,
240
291
  settings: CompactionSettings,
@@ -265,23 +316,55 @@ export function resolveThresholdTokens(
265
316
  * matching what providers typically bill for inline images.
266
317
  */
267
318
  const IMAGE_TOKEN_ESTIMATE = 1200;
319
+ const SOURCE_NATIVE_TOKENIZER_ENTRYPOINT = "../../../natives/native/index.js";
320
+ const COMPILED_NATIVE_TOKENIZER_ENTRYPOINT = "/$bunfs/root/packages/natives/native/index.js";
321
+
322
+ const requireFromCompaction = createRequire(import.meta.url);
323
+
324
+ interface NativeTokenizerModule {
325
+ countTokens(input: string | string[], encoding?: unknown): number;
326
+ }
268
327
 
269
328
  /**
270
329
  * Lazily-required native `countTokens`. `@gajae-code/natives` dlopens a ~39MB
271
330
  * addon; importing it at module scope would put that cost on every cold path
272
331
  * that touches compaction exports (status line, print mode, context report).
273
- * Deferring the require to the first context-changing call keeps the trivial
274
- * `-p` / display paths native-free.
332
+ * Deferring the require to the first context-changing call keeps display paths
333
+ * native-free.
334
+ *
335
+ * Do not resolve this via a package-name dynamic require of
336
+ * `@gajae-code/natives`: Bun standalone binaries cannot satisfy those from
337
+ * `$bunfs`. The sibling-package source path is stable for workspace and
338
+ * package-install layouts:
339
+ *
340
+ * - workspace: `packages/agent` -> `packages/natives`
341
+ * - npm/bun install: `node_modules/@gajae-code/agent-core` ->
342
+ * `node_modules/@gajae-code/natives`
343
+ *
344
+ * Bun rewrites `createRequire(import.meta.url)` to the compiled executable
345
+ * root (`/$bunfs/root/gjc-*`) in standalone binaries, so compiled mode uses the
346
+ * absolute bunfs module path emitted by the binary build scripts.
275
347
  */
276
348
  let cachedNativeCountTokens: ((input: string | string[], encoding?: unknown) => number) | null = null;
277
349
 
350
+ function nativeTokenizerEntrypoint(): string {
351
+ return isCompiledBinary() ? COMPILED_NATIVE_TOKENIZER_ENTRYPOINT : SOURCE_NATIVE_TOKENIZER_ENTRYPOINT;
352
+ }
353
+
354
+ /** Max total fragment chars sent to the synchronous native tokenizer (F22). */
355
+ const MAX_NATIVE_TOKENIZE_CHARS = 2 * 1024 * 1024;
356
+
278
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
+ }
279
366
  if (!cachedNativeCountTokens) {
280
- const { createRequire } = require("node:module") as typeof import("node:module");
281
- const requireFromHere = createRequire(import.meta.url);
282
- const natives = requireFromHere("@gajae-code/natives") as {
283
- countTokens: (input: string | string[], encoding?: unknown) => number;
284
- };
367
+ const natives = requireFromCompaction(nativeTokenizerEntrypoint()) as NativeTokenizerModule;
285
368
  cachedNativeCountTokens = natives.countTokens;
286
369
  }
287
370
  return cachedNativeCountTokens(fragments);