@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 +6 -0
- package/dist/types/compaction/compaction.d.ts +31 -0
- package/package.json +4 -4
- package/src/agent.ts +4 -1
- package/src/append-only-context.ts +53 -39
- package/src/compaction/compaction.ts +61 -0
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.
|
|
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.
|
|
39
|
-
"@gajae-code/natives": "0.5.
|
|
40
|
-
"@gajae-code/utils": "0.5.
|
|
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
|
-
|
|
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
|
-
/**
|
|
203
|
-
#
|
|
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.#
|
|
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.#
|
|
250
|
+
this.#prefixChanged(messagesToSync, this.#lastSyncCount)
|
|
251
251
|
) {
|
|
252
252
|
if (this.#seededPrefixCount > 0) {
|
|
253
|
-
|
|
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
|
-
//
|
|
261
|
-
//
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
330
|
+
this.#syncedHashes = [];
|
|
325
331
|
this.#seededPrefixCount = 0;
|
|
326
332
|
this.prefix.build(context, options);
|
|
327
333
|
}
|
|
328
334
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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;
|