@gajae-code/agent-core 0.4.5 → 0.5.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.
- package/CHANGELOG.md +15 -0
- package/dist/types/agent.d.ts +2 -0
- package/dist/types/append-only-context.d.ts +1 -0
- package/dist/types/compaction/compaction.d.ts +2 -0
- package/dist/types/compaction/entries.d.ts +6 -0
- package/dist/types/compaction/openai.d.ts +2 -0
- package/dist/types/types.d.ts +4 -0
- package/package.json +4 -4
- package/src/agent-loop.ts +4 -0
- package/src/agent.ts +10 -0
- package/src/append-only-context.ts +109 -35
- package/src/compaction/compaction.ts +42 -9
- package/src/compaction/entries.ts +6 -0
- package/src/compaction/openai.ts +15 -6
- package/src/compaction/pruning.ts +78 -9
- package/src/types.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.5.1] - 2026-06-14
|
|
6
|
+
|
|
7
|
+
- Version aligned with the 0.5.1 monorepo release; no functional changes in this package.
|
|
8
|
+
|
|
9
|
+
## [0.5.0] - 2026-06-13
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
5
20
|
## [0.4.5] - 2026-06-12
|
|
6
21
|
|
|
7
22
|
### Changed
|
package/dist/types/agent.d.ts
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
|
*/
|
|
@@ -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
|
*
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
+
"version": "0.5.1",
|
|
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.
|
|
39
|
-
"@gajae-code/natives": "0.
|
|
40
|
-
"@gajae-code/utils": "0.
|
|
38
|
+
"@gajae-code/ai": "0.5.1",
|
|
39
|
+
"@gajae-code/natives": "0.5.1",
|
|
40
|
+
"@gajae-code/utils": "0.5.1",
|
|
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
|
-
/**
|
|
179
|
-
#syncedDigest =
|
|
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
|
|
235
|
+
const seededPrefixLength = this.#seededPrefixCount;
|
|
212
236
|
const includesSeedPrefix =
|
|
213
|
-
|
|
214
|
-
normalizedMessages.length >=
|
|
215
|
-
this.#
|
|
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
|
-
|
|
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.#
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
304
|
-
*
|
|
305
|
-
*
|
|
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[]):
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 {
|
package/src/compaction/openai.ts
CHANGED
|
@@ -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 &&
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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 +=
|
|
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:
|
|
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
|
*/
|