@sayknow-cli/agent-core 0.3.7 → 0.3.9
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 +9 -0
- package/dist/types/agent.d.ts +4 -0
- package/dist/types/append-only-context.d.ts +13 -0
- package/dist/types/compaction/compaction.d.ts +49 -1
- package/dist/types/compaction/pruning.d.ts +22 -0
- package/package.json +5 -5
- package/src/agent-loop.ts +132 -5
- package/src/agent.ts +28 -0
- package/src/append-only-context.ts +28 -6
- package/src/compaction/compaction.ts +87 -14
- package/src/compaction/pruning.ts +61 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.8.2] - 2026-07-06
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- Agent queues now expose ordered move helpers for steering and follow-up messages so callers can reorder pending work without removing and re-adding messages.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Preserved inherited fork-context seed messages when a compacted child rebase receives only child-local normalized messages, avoiding seed loss after task-child compaction (#1567).
|
|
13
|
+
|
|
5
14
|
## [0.7.7] - 2026-06-28
|
|
6
15
|
|
|
7
16
|
### Fixed
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -349,11 +349,15 @@ export declare class Agent {
|
|
|
349
349
|
* Used by dequeue keybinding.
|
|
350
350
|
*/
|
|
351
351
|
popLastSteer(): AgentMessage | undefined;
|
|
352
|
+
removeSteerAt(index: number): AgentMessage | undefined;
|
|
353
|
+
moveSteer(fromIndex: number, toIndex: number): boolean;
|
|
352
354
|
/**
|
|
353
355
|
* Remove and return the last follow-up message from the queue (LIFO).
|
|
354
356
|
* Used by dequeue keybinding.
|
|
355
357
|
*/
|
|
356
358
|
popLastFollowUp(): AgentMessage | undefined;
|
|
359
|
+
removeFollowUpAt(index: number): AgentMessage | undefined;
|
|
360
|
+
moveFollowUp(fromIndex: number, toIndex: number): boolean;
|
|
357
361
|
/** Remove queued steering+follow-up messages matching `predicate`, preserving order of the rest. */
|
|
358
362
|
removeQueuedMessages(predicate: (message: AgentMessage) => boolean): {
|
|
359
363
|
steering: number;
|
|
@@ -92,10 +92,23 @@ export declare class AppendOnlyLog {
|
|
|
92
92
|
* ctx = mgr.build(context); // subsequent calls use cache
|
|
93
93
|
* ```
|
|
94
94
|
*/
|
|
95
|
+
export interface AppendOnlyContextManagerOptions {
|
|
96
|
+
/**
|
|
97
|
+
* Invoked whenever the stable prefix fingerprint changes on `build()` (a
|
|
98
|
+
* provider prompt-cache prefix reset). Used for per-session diagnostics; must
|
|
99
|
+
* not throw. `from` is `<unbuilt>` on the first build.
|
|
100
|
+
*/
|
|
101
|
+
readonly onPrefixChange?: (info: {
|
|
102
|
+
from: string;
|
|
103
|
+
to: string;
|
|
104
|
+
version: number;
|
|
105
|
+
}) => void;
|
|
106
|
+
}
|
|
95
107
|
export declare class AppendOnlyContextManager {
|
|
96
108
|
#private;
|
|
97
109
|
readonly prefix: StablePrefix;
|
|
98
110
|
readonly log: AppendOnlyLog;
|
|
111
|
+
constructor(options?: AppendOnlyContextManagerOptions);
|
|
99
112
|
static forkFromSeed(args: {
|
|
100
113
|
prefixSnapshot?: StablePrefixSnapshot;
|
|
101
114
|
messages?: readonly Message[];
|
|
@@ -98,6 +98,11 @@ export declare const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLim
|
|
|
98
98
|
*/
|
|
99
99
|
export declare function emergencyCompactionReason(sample: EmergencyCompactionSample, limits?: EmergencyCompactionLimits): CompactionTriggerReason | null;
|
|
100
100
|
export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): number;
|
|
101
|
+
/**
|
|
102
|
+
* Image content has no tokenizer representation; charge a fixed estimate
|
|
103
|
+
* matching what providers typically bill for inline images.
|
|
104
|
+
*/
|
|
105
|
+
export declare const IMAGE_TOKEN_ESTIMATE = 1200;
|
|
101
106
|
/**
|
|
102
107
|
* Native-free chars/4 token estimate for a message. This is the only message
|
|
103
108
|
* token estimator: provider usage (see {@link calculatePromptTokens}) anchors
|
|
@@ -176,6 +181,25 @@ export interface SummaryOptions {
|
|
|
176
181
|
/** Hint that websocket transport should be preferred when supported by the provider implementation. */
|
|
177
182
|
preferWebsockets?: boolean;
|
|
178
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Cap the serialized conversation fed to a summarization request so the request
|
|
186
|
+
* itself fits inside the model's context window.
|
|
187
|
+
*
|
|
188
|
+
* Without this, summarizing a near-full context serializes (nearly) the entire
|
|
189
|
+
* history back into a single summary request; on strict backends (e.g.
|
|
190
|
+
* OpenAI-code/Codex `context_length_exceeded`) that request itself overflows and
|
|
191
|
+
* throws, so context-overflow recovery cannot produce a summary and the agent
|
|
192
|
+
* fails to compact-and-continue — a non-interactive `skc -p` run then terminates
|
|
193
|
+
* on the very overflow the recovery was meant to absorb.
|
|
194
|
+
*
|
|
195
|
+
* The budget reserves the summary's own output tokens plus prompt/system/template
|
|
196
|
+
* overhead, and applies a conservative safety factor because the chars/4 heuristic
|
|
197
|
+
* undercounts dense or CJK text (the reason the original overflow was missed).
|
|
198
|
+
* Truncation keeps the head (origin/goals) and the tail (most recent state) and
|
|
199
|
+
* elides the middle; it is a last resort that only triggers when the input would
|
|
200
|
+
* otherwise not fit.
|
|
201
|
+
*/
|
|
202
|
+
export declare function boundConversationTextForSummary(conversationText: string, model: Model, outputMaxTokens: number): string;
|
|
179
203
|
export declare function generateSummary(currentMessages: AgentMessage[], model: Model, reserveTokens: number, apiKey: string, signal?: AbortSignal, customInstructions?: string, previousSummary?: string, options?: SummaryOptions): Promise<string>;
|
|
180
204
|
export interface HandoffOptions {
|
|
181
205
|
/** Live agent system prompt — passed verbatim so providers hit the cached prefix. */
|
|
@@ -224,8 +248,32 @@ export interface CompactionPreparation {
|
|
|
224
248
|
fileOps: FileOperations;
|
|
225
249
|
/** Compaction settions from settings.jsonl */
|
|
226
250
|
settings: CompactionSettings;
|
|
251
|
+
/**
|
|
252
|
+
* Diagnostics for the keep-window token correction (Finding 7). `ratio` is the
|
|
253
|
+
* clamped heuristic→actual correction that was applied (1 when none supplied);
|
|
254
|
+
* `keepRecentTokensCorrected` is the heuristic budget findCutPoint actually used.
|
|
255
|
+
*/
|
|
256
|
+
tokenCorrection: {
|
|
257
|
+
ratio: number;
|
|
258
|
+
keepRecentTokensCorrected: number;
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/** Bounds for the keep-window token correction (Finding 7): never trust a ratio
|
|
262
|
+
* beyond 2x in either direction so a bad estimate cannot balloon or collapse the
|
|
263
|
+
* kept window. */
|
|
264
|
+
export declare const TOKEN_CORRECTION_MIN_RATIO = 0.5;
|
|
265
|
+
export declare const TOKEN_CORRECTION_MAX_RATIO = 2;
|
|
266
|
+
export interface PrepareCompactionOptions {
|
|
267
|
+
/**
|
|
268
|
+
* Observed heuristic→actual token correction for the post-boundary keep window
|
|
269
|
+
* (actualTokens / chars-4-heuristicTokens), supplied by the caller from per-turn
|
|
270
|
+
* Usage deltas or a stable-prefix-subtracted comparison. Clamped to
|
|
271
|
+
* [0.5, 2] and applied bidirectionally. When omitted, no correction is applied
|
|
272
|
+
* (the confounded raw promptTokens/estimatedTokens quotient is never used).
|
|
273
|
+
*/
|
|
274
|
+
tokenCorrectionRatio?: number;
|
|
227
275
|
}
|
|
228
|
-
export declare function prepareCompaction(pathEntries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | undefined;
|
|
276
|
+
export declare function prepareCompaction(pathEntries: SessionEntry[], settings: CompactionSettings, options?: PrepareCompactionOptions): CompactionPreparation | undefined;
|
|
229
277
|
/**
|
|
230
278
|
* Generate summaries for compaction using prepared data.
|
|
231
279
|
* Returns CompactionResult - SessionManager adds id/parentId when saving.
|
|
@@ -44,4 +44,26 @@ export interface AssistantArgumentPruneResult {
|
|
|
44
44
|
prunedEntries: SessionMessageEntry[];
|
|
45
45
|
}
|
|
46
46
|
export declare function pruneAssistantToolArguments(entries: SessionEntry[], config?: PruneConfig): AssistantArgumentPruneResult;
|
|
47
|
+
/**
|
|
48
|
+
* Estimate the token savings {@link pruneToolOutputs} would achieve, without
|
|
49
|
+
* mutating any entry. Returns 0 savings when below the configured minimum so the
|
|
50
|
+
* caller sees the same gate the real prune enforces.
|
|
51
|
+
*/
|
|
52
|
+
export declare function estimateToolOutputPruneSavings(entries: SessionEntry[], config?: PruneConfig): {
|
|
53
|
+
prunableCount: number;
|
|
54
|
+
tokensSaved: number;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Evidence gate for below-threshold maintenance pruning (Finding 13). Pruning
|
|
58
|
+
* forces a prompt-cache-epoch reset, so it only runs when opted in AND the
|
|
59
|
+
* estimated stale savings clear a high minimum AND exceed the one-time reset
|
|
60
|
+
* cost (so the reclaim pays the reset back). Default-off/blocked until live
|
|
61
|
+
* evidence justifies enabling.
|
|
62
|
+
*/
|
|
63
|
+
export declare function shouldRunMaintenancePrune(args: {
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
estimatedSavings: number;
|
|
66
|
+
minSavings: number;
|
|
67
|
+
cacheEpochResetCost: number;
|
|
68
|
+
}): boolean;
|
|
47
69
|
export declare function pruneToolOutputs(entries: SessionEntry[], config?: PruneConfig): PruneResult;
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/agent-core",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.9",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
|
-
"homepage": "https://
|
|
6
|
+
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
8
8
|
"contributors": [
|
|
9
9
|
"Mario Zechner"
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@sayknow-cli/ai": "0.3.
|
|
39
|
-
"@sayknow-cli/natives": "0.3.
|
|
40
|
-
"@sayknow-cli/utils": "0.3.
|
|
38
|
+
"@sayknow-cli/ai": "0.3.9",
|
|
39
|
+
"@sayknow-cli/natives": "0.3.9",
|
|
40
|
+
"@sayknow-cli/utils": "0.3.9",
|
|
41
41
|
"@opentelemetry/api": "^1.9.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -342,9 +342,136 @@ export function normalizeMessagesForProvider(
|
|
|
342
342
|
return changed ? normalized : messages;
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
interface ConvertedContextCacheEntry {
|
|
346
|
+
messageHashes: string[];
|
|
347
|
+
modelKey: string;
|
|
348
|
+
toolKey: string;
|
|
349
|
+
intentTracing: boolean;
|
|
350
|
+
convertToLlm: AgentLoopConfig["convertToLlm"];
|
|
351
|
+
transformContext: AgentLoopConfig["transformContext"];
|
|
352
|
+
llmMessages: Context["messages"];
|
|
353
|
+
normalizedMessages: Context["messages"];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const convertedContextCache = new WeakMap<AgentLoopConfig, ConvertedContextCacheEntry>();
|
|
357
|
+
|
|
358
|
+
function stableCacheString(value: unknown): string | undefined {
|
|
359
|
+
try {
|
|
360
|
+
return JSON.stringify(value, (_key, item) =>
|
|
361
|
+
typeof item === "function" ? `[Function:${item.name || "anonymous"}]` : item,
|
|
362
|
+
);
|
|
363
|
+
} catch {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Hash a message by full content serialization.
|
|
370
|
+
*
|
|
371
|
+
* Deliberately NOT memoized by object identity: callers mutate messages in
|
|
372
|
+
* place (compaction rewrites, obfuscation, abort markers) and the cache's
|
|
373
|
+
* correctness contract requires detecting those mutations. The per-turn
|
|
374
|
+
* serialization cost is the price of that contract; the win is skipping
|
|
375
|
+
* convertToLlm + normalize on stable contexts, which dominates for
|
|
376
|
+
* image-heavy histories.
|
|
377
|
+
*/
|
|
378
|
+
function hashMessageContent(message: AgentMessage): string | undefined {
|
|
379
|
+
return stableCacheString(message);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function buildConvertedContextCacheKeys(
|
|
383
|
+
messages: AgentMessage[],
|
|
384
|
+
context: AgentContext,
|
|
385
|
+
config: AgentLoopConfig,
|
|
386
|
+
): Pick<ConvertedContextCacheEntry, "messageHashes" | "modelKey" | "toolKey" | "intentTracing"> | undefined {
|
|
387
|
+
const intentTracing = !!config.intentTracing;
|
|
388
|
+
const messageHashes = messages.map(hashMessageContent);
|
|
389
|
+
const modelKey = stableCacheString(config.model);
|
|
390
|
+
const toolKey = stableCacheString(normalizeTools(context.tools, intentTracing) ?? []);
|
|
391
|
+
if (messageHashes.some(hash => hash === undefined) || modelKey === undefined || toolKey === undefined) {
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
messageHashes: messageHashes as string[],
|
|
396
|
+
modelKey,
|
|
397
|
+
toolKey,
|
|
398
|
+
intentTracing,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function findStablePrefixLength(previous: string[], next: string[]): number {
|
|
403
|
+
const max = Math.min(previous.length, next.length);
|
|
404
|
+
let index = 0;
|
|
405
|
+
while (index < max && previous[index] === next[index]) index++;
|
|
406
|
+
return index;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function convertAndNormalizeMessages(
|
|
410
|
+
messages: AgentMessage[],
|
|
411
|
+
context: AgentContext,
|
|
412
|
+
config: AgentLoopConfig,
|
|
413
|
+
): Promise<Context["messages"]> {
|
|
414
|
+
const keys = buildConvertedContextCacheKeys(messages, context, config);
|
|
415
|
+
if (!keys) {
|
|
416
|
+
return normalizeMessagesForProvider(await config.convertToLlm(messages), config.model);
|
|
417
|
+
}
|
|
418
|
+
const previous = convertedContextCache.get(config);
|
|
419
|
+
const canReuse =
|
|
420
|
+
previous &&
|
|
421
|
+
previous.convertToLlm === config.convertToLlm &&
|
|
422
|
+
previous.transformContext === config.transformContext &&
|
|
423
|
+
previous.modelKey === keys.modelKey &&
|
|
424
|
+
previous.toolKey === keys.toolKey &&
|
|
425
|
+
previous.intentTracing === keys.intentTracing;
|
|
426
|
+
|
|
427
|
+
if (canReuse) {
|
|
428
|
+
const stablePrefixLength = findStablePrefixLength(previous.messageHashes, keys.messageHashes);
|
|
429
|
+
if (stablePrefixLength === keys.messageHashes.length && stablePrefixLength === previous.messageHashes.length) {
|
|
430
|
+
return previous.normalizedMessages;
|
|
431
|
+
}
|
|
432
|
+
// Append-only fast path: convert only the new suffix and concatenate.
|
|
433
|
+
// CONTRACT: `convertToLlm` must be per-message (each output message
|
|
434
|
+
// derived solely from its input message). The bundled converters
|
|
435
|
+
// satisfy this — they map/filter message-by-message. A converter that
|
|
436
|
+
// merges adjacent messages or pairs across the suffix boundary would
|
|
437
|
+
// diverge from a full rebuild; such converters must not be combined
|
|
438
|
+
// with appendOnlyContext. Covered by the suffix-equivalence test in
|
|
439
|
+
// agent-loop-context-cache.test.ts.
|
|
440
|
+
if (
|
|
441
|
+
config.appendOnlyContext &&
|
|
442
|
+
stablePrefixLength === previous.messageHashes.length &&
|
|
443
|
+
keys.messageHashes.length > previous.messageHashes.length
|
|
444
|
+
) {
|
|
445
|
+
const suffix = messages.slice(stablePrefixLength);
|
|
446
|
+
const convertedSuffix = await config.convertToLlm(suffix);
|
|
447
|
+
const llmMessages = [...previous.llmMessages, ...convertedSuffix];
|
|
448
|
+
const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
|
|
449
|
+
convertedContextCache.set(config, {
|
|
450
|
+
...keys,
|
|
451
|
+
convertToLlm: config.convertToLlm,
|
|
452
|
+
transformContext: config.transformContext,
|
|
453
|
+
llmMessages,
|
|
454
|
+
normalizedMessages,
|
|
455
|
+
});
|
|
456
|
+
return normalizedMessages;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const llmMessages = await config.convertToLlm(messages);
|
|
461
|
+
const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
|
|
462
|
+
convertedContextCache.set(config, {
|
|
463
|
+
...keys,
|
|
464
|
+
convertToLlm: config.convertToLlm,
|
|
465
|
+
transformContext: config.transformContext,
|
|
466
|
+
llmMessages,
|
|
467
|
+
normalizedMessages,
|
|
468
|
+
});
|
|
469
|
+
return normalizedMessages;
|
|
470
|
+
}
|
|
471
|
+
|
|
345
472
|
export const INTENT_FIELD = "_i";
|
|
346
473
|
|
|
347
|
-
function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = "
|
|
474
|
+
function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = "optional"): unknown {
|
|
348
475
|
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
|
|
349
476
|
const schemaRecord = schema as Record<string, unknown>;
|
|
350
477
|
const propertiesValue = schemaRecord.properties;
|
|
@@ -400,7 +527,7 @@ export function normalizeTools(tools: AgentContext["tools"], injectIntent: boole
|
|
|
400
527
|
function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional" | "omit" {
|
|
401
528
|
if (typeof intent === "function") return "omit";
|
|
402
529
|
if (intent === "optional" || intent === "omit") return intent;
|
|
403
|
-
return "require";
|
|
530
|
+
return intent === "require" ? "require" : "optional";
|
|
404
531
|
}
|
|
405
532
|
|
|
406
533
|
function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
|
|
@@ -711,9 +838,9 @@ async function streamAssistantResponse(
|
|
|
711
838
|
messages = await config.transformContext(messages, signal);
|
|
712
839
|
}
|
|
713
840
|
|
|
714
|
-
// Convert to LLM-compatible messages (AgentMessage[] → Message[])
|
|
715
|
-
|
|
716
|
-
const normalizedMessages =
|
|
841
|
+
// Convert to LLM-compatible messages (AgentMessage[] → Message[]) and normalize at the LLM boundary.
|
|
842
|
+
// Cache hits are keyed by provider-visible content hashes, never message object identity.
|
|
843
|
+
const normalizedMessages = await convertAndNormalizeMessages(messages, context, config);
|
|
717
844
|
|
|
718
845
|
// Build LLM context — append-only mode caches system prompt + tools
|
|
719
846
|
// AND keeps an append-only message log so prior-turn bytes are stable.
|
package/src/agent.ts
CHANGED
|
@@ -954,6 +954,15 @@ export class Agent {
|
|
|
954
954
|
popLastSteer(): AgentMessage | undefined {
|
|
955
955
|
return this.#steeringQueue.pop();
|
|
956
956
|
}
|
|
957
|
+
removeSteerAt(index: number): AgentMessage | undefined {
|
|
958
|
+
if (index < 0 || index >= this.#steeringQueue.length) return undefined;
|
|
959
|
+
const [removed] = this.#steeringQueue.splice(index, 1);
|
|
960
|
+
return removed;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
moveSteer(fromIndex: number, toIndex: number): boolean {
|
|
964
|
+
return this.#moveQueuedMessage(this.#steeringQueue, fromIndex, toIndex);
|
|
965
|
+
}
|
|
957
966
|
|
|
958
967
|
/**
|
|
959
968
|
* Remove and return the last follow-up message from the queue (LIFO).
|
|
@@ -962,6 +971,25 @@ export class Agent {
|
|
|
962
971
|
popLastFollowUp(): AgentMessage | undefined {
|
|
963
972
|
return this.#followUpQueue.pop();
|
|
964
973
|
}
|
|
974
|
+
removeFollowUpAt(index: number): AgentMessage | undefined {
|
|
975
|
+
if (index < 0 || index >= this.#followUpQueue.length) return undefined;
|
|
976
|
+
const [removed] = this.#followUpQueue.splice(index, 1);
|
|
977
|
+
return removed;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
moveFollowUp(fromIndex: number, toIndex: number): boolean {
|
|
981
|
+
return this.#moveQueuedMessage(this.#followUpQueue, fromIndex, toIndex);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
#moveQueuedMessage<T>(queue: T[], fromIndex: number, toIndex: number): boolean {
|
|
985
|
+
if (fromIndex < 0 || fromIndex >= queue.length) return false;
|
|
986
|
+
if (toIndex < 0 || toIndex >= queue.length) return false;
|
|
987
|
+
if (fromIndex === toIndex) return true;
|
|
988
|
+
const [item] = queue.splice(fromIndex, 1);
|
|
989
|
+
if (item === undefined) return false;
|
|
990
|
+
queue.splice(toIndex, 0, item);
|
|
991
|
+
return true;
|
|
992
|
+
}
|
|
965
993
|
|
|
966
994
|
/** Remove queued steering+follow-up messages matching `predicate`, preserving order of the rest. */
|
|
967
995
|
removeQueuedMessages(predicate: (message: AgentMessage) => boolean): {
|
|
@@ -194,6 +194,15 @@ export class AppendOnlyLog {
|
|
|
194
194
|
* ctx = mgr.build(context); // subsequent calls use cache
|
|
195
195
|
* ```
|
|
196
196
|
*/
|
|
197
|
+
export interface AppendOnlyContextManagerOptions {
|
|
198
|
+
/**
|
|
199
|
+
* Invoked whenever the stable prefix fingerprint changes on `build()` (a
|
|
200
|
+
* provider prompt-cache prefix reset). Used for per-session diagnostics; must
|
|
201
|
+
* not throw. `from` is `<unbuilt>` on the first build.
|
|
202
|
+
*/
|
|
203
|
+
readonly onPrefixChange?: (info: { from: string; to: string; version: number }) => void;
|
|
204
|
+
}
|
|
205
|
+
|
|
197
206
|
export class AppendOnlyContextManager {
|
|
198
207
|
readonly prefix = new StablePrefix();
|
|
199
208
|
readonly log = new AppendOnlyLog();
|
|
@@ -203,6 +212,11 @@ export class AppendOnlyContextManager {
|
|
|
203
212
|
#syncedHashes: (number | bigint)[] = [];
|
|
204
213
|
/** Number of provider-normalized messages that were seeded before child-local messages. */
|
|
205
214
|
#seededPrefixCount = 0;
|
|
215
|
+
readonly #onPrefixChange: AppendOnlyContextManagerOptions["onPrefixChange"];
|
|
216
|
+
|
|
217
|
+
constructor(options: AppendOnlyContextManagerOptions = {}) {
|
|
218
|
+
this.#onPrefixChange = options.onPrefixChange;
|
|
219
|
+
}
|
|
206
220
|
|
|
207
221
|
static forkFromSeed(args: {
|
|
208
222
|
prefixSnapshot?: StablePrefixSnapshot;
|
|
@@ -220,7 +234,15 @@ export class AppendOnlyContextManager {
|
|
|
220
234
|
}
|
|
221
235
|
|
|
222
236
|
build(context: AgentContext, options: BuildOptions): Context {
|
|
223
|
-
this.prefix.
|
|
237
|
+
const previousFingerprint = this.prefix.fingerprint;
|
|
238
|
+
const changed = this.prefix.build(context, options);
|
|
239
|
+
if (changed && this.#onPrefixChange) {
|
|
240
|
+
this.#onPrefixChange({
|
|
241
|
+
from: previousFingerprint,
|
|
242
|
+
to: this.prefix.fingerprint,
|
|
243
|
+
version: this.prefix.version,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
224
246
|
const { systemPrompt, tools } = this.prefix.toContext();
|
|
225
247
|
return { systemPrompt, messages: this.log.toMessages(), tools };
|
|
226
248
|
}
|
|
@@ -252,7 +274,7 @@ export class AppendOnlyContextManager {
|
|
|
252
274
|
if (this.#seededPrefixCount > 0) {
|
|
253
275
|
// F9: a seeded fork whose inherited prefix changed (e.g. after compaction)
|
|
254
276
|
// rebases onto the new provider context instead of throwing.
|
|
255
|
-
this.#rebaseToBaseline(
|
|
277
|
+
this.#rebaseToBaseline(messagesToSync, seededPrefixLength);
|
|
256
278
|
return;
|
|
257
279
|
}
|
|
258
280
|
this.log.clear();
|
|
@@ -265,7 +287,7 @@ export class AppendOnlyContextManager {
|
|
|
265
287
|
// while a seed prefix is active; a genuine seeded compaction rebases (F9).
|
|
266
288
|
if (messagesToSync.length < this.#lastSyncCount) {
|
|
267
289
|
if (this.#seededPrefixCount > 0) {
|
|
268
|
-
this.#rebaseToBaseline(
|
|
290
|
+
this.#rebaseToBaseline(messagesToSync, seededPrefixLength);
|
|
269
291
|
return;
|
|
270
292
|
}
|
|
271
293
|
this.log.clear();
|
|
@@ -359,12 +381,12 @@ export class AppendOnlyContextManager {
|
|
|
359
381
|
return false;
|
|
360
382
|
}
|
|
361
383
|
|
|
362
|
-
/** F9: reset the
|
|
363
|
-
#rebaseToBaseline(messages: readonly unknown[]): void {
|
|
384
|
+
/** F9: reset the log to a new provider-visible baseline after seeded compaction/rebase. */
|
|
385
|
+
#rebaseToBaseline(messages: readonly unknown[], seededPrefixCount = 0): void {
|
|
364
386
|
this.log.clear();
|
|
365
387
|
this.log.extend([...messages]);
|
|
366
388
|
this.#lastSyncCount = messages.length;
|
|
367
|
-
this.#seededPrefixCount =
|
|
389
|
+
this.#seededPrefixCount = seededPrefixCount;
|
|
368
390
|
this.#syncedHashes = this.#hashRange(messages, 0, messages.length);
|
|
369
391
|
}
|
|
370
392
|
}
|
|
@@ -315,7 +315,7 @@ export function resolveThresholdTokens(
|
|
|
315
315
|
* Image content has no tokenizer representation; charge a fixed estimate
|
|
316
316
|
* matching what providers typically bill for inline images.
|
|
317
317
|
*/
|
|
318
|
-
const IMAGE_TOKEN_ESTIMATE = 1200;
|
|
318
|
+
export const IMAGE_TOKEN_ESTIMATE = 1200;
|
|
319
319
|
/**
|
|
320
320
|
* Estimate tokens for collected message fragments using the native-free
|
|
321
321
|
* heuristic. Provider usage is the authoritative anchor for context-changing
|
|
@@ -679,6 +679,49 @@ export interface SummaryOptions {
|
|
|
679
679
|
preferWebsockets?: boolean;
|
|
680
680
|
}
|
|
681
681
|
|
|
682
|
+
/**
|
|
683
|
+
* Cap the serialized conversation fed to a summarization request so the request
|
|
684
|
+
* itself fits inside the model's context window.
|
|
685
|
+
*
|
|
686
|
+
* Without this, summarizing a near-full context serializes (nearly) the entire
|
|
687
|
+
* history back into a single summary request; on strict backends (e.g.
|
|
688
|
+
* OpenAI-code/Codex `context_length_exceeded`) that request itself overflows and
|
|
689
|
+
* throws, so context-overflow recovery cannot produce a summary and the agent
|
|
690
|
+
* fails to compact-and-continue — a non-interactive `skc -p` run then terminates
|
|
691
|
+
* on the very overflow the recovery was meant to absorb.
|
|
692
|
+
*
|
|
693
|
+
* The budget reserves the summary's own output tokens plus prompt/system/template
|
|
694
|
+
* overhead, and applies a conservative safety factor because the chars/4 heuristic
|
|
695
|
+
* undercounts dense or CJK text (the reason the original overflow was missed).
|
|
696
|
+
* Truncation keeps the head (origin/goals) and the tail (most recent state) and
|
|
697
|
+
* elides the middle; it is a last resort that only triggers when the input would
|
|
698
|
+
* otherwise not fit.
|
|
699
|
+
*/
|
|
700
|
+
export function boundConversationTextForSummary(
|
|
701
|
+
conversationText: string,
|
|
702
|
+
model: Model,
|
|
703
|
+
outputMaxTokens: number,
|
|
704
|
+
): string {
|
|
705
|
+
const contextWindow = model.contextWindow;
|
|
706
|
+
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return conversationText;
|
|
707
|
+
|
|
708
|
+
const OVERHEAD_TOKENS = 4096;
|
|
709
|
+
const SAFETY_FACTOR = 0.6;
|
|
710
|
+
const inputBudgetTokens = Math.floor(
|
|
711
|
+
(contextWindow - Math.max(0, outputMaxTokens) - OVERHEAD_TOKENS) * SAFETY_FACTOR,
|
|
712
|
+
);
|
|
713
|
+
if (inputBudgetTokens <= 0) return conversationText;
|
|
714
|
+
if (estimateTextTokensHeuristic(conversationText) <= inputBudgetTokens) return conversationText;
|
|
715
|
+
|
|
716
|
+
const budgetChars = inputBudgetTokens * HEURISTIC_BYTES_PER_TOKEN;
|
|
717
|
+
const headChars = Math.floor(budgetChars * 0.35);
|
|
718
|
+
const tailChars = Math.max(0, budgetChars - headChars);
|
|
719
|
+
const head = conversationText.slice(0, headChars);
|
|
720
|
+
const tail = tailChars > 0 ? conversationText.slice(conversationText.length - tailChars) : "";
|
|
721
|
+
const elided = conversationText.length - head.length - tail.length;
|
|
722
|
+
return `${head}\n\n[... ${elided} characters of older conversation elided so this summarization request fits within the model context window ...]\n\n${tail}`;
|
|
723
|
+
}
|
|
724
|
+
|
|
682
725
|
export async function generateSummary(
|
|
683
726
|
currentMessages: AgentMessage[],
|
|
684
727
|
model: Model,
|
|
@@ -703,7 +746,7 @@ export async function generateSummary(
|
|
|
703
746
|
// Serialize conversation to text so model doesn't try to continue it
|
|
704
747
|
// Convert to LLM messages first (handles custom app messages when caller provides a transformer).
|
|
705
748
|
const llmMessages = (options?.convertToLlm ?? convertToLlm)(currentMessages);
|
|
706
|
-
const conversationText = serializeConversation(llmMessages);
|
|
749
|
+
const conversationText = boundConversationTextForSummary(serializeConversation(llmMessages), model, maxTokens);
|
|
707
750
|
|
|
708
751
|
// Build the prompt with conversation wrapped in tags
|
|
709
752
|
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
|
@@ -859,7 +902,7 @@ async function generateShortSummary(
|
|
|
859
902
|
): Promise<string> {
|
|
860
903
|
const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
|
|
861
904
|
const llmMessages = (options?.convertToLlm ?? convertToLlm)(recentMessages);
|
|
862
|
-
const conversationText = serializeConversation(llmMessages);
|
|
905
|
+
const conversationText = boundConversationTextForSummary(serializeConversation(llmMessages), model, maxTokens);
|
|
863
906
|
|
|
864
907
|
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
|
865
908
|
if (historySummary) {
|
|
@@ -934,11 +977,35 @@ export interface CompactionPreparation {
|
|
|
934
977
|
fileOps: FileOperations;
|
|
935
978
|
/** Compaction settions from settings.jsonl */
|
|
936
979
|
settings: CompactionSettings;
|
|
980
|
+
/**
|
|
981
|
+
* Diagnostics for the keep-window token correction (Finding 7). `ratio` is the
|
|
982
|
+
* clamped heuristic→actual correction that was applied (1 when none supplied);
|
|
983
|
+
* `keepRecentTokensCorrected` is the heuristic budget findCutPoint actually used.
|
|
984
|
+
*/
|
|
985
|
+
tokenCorrection: { ratio: number; keepRecentTokensCorrected: number };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** Bounds for the keep-window token correction (Finding 7): never trust a ratio
|
|
989
|
+
* beyond 2x in either direction so a bad estimate cannot balloon or collapse the
|
|
990
|
+
* kept window. */
|
|
991
|
+
export const TOKEN_CORRECTION_MIN_RATIO = 0.5;
|
|
992
|
+
export const TOKEN_CORRECTION_MAX_RATIO = 2;
|
|
993
|
+
|
|
994
|
+
export interface PrepareCompactionOptions {
|
|
995
|
+
/**
|
|
996
|
+
* Observed heuristic→actual token correction for the post-boundary keep window
|
|
997
|
+
* (actualTokens / chars-4-heuristicTokens), supplied by the caller from per-turn
|
|
998
|
+
* Usage deltas or a stable-prefix-subtracted comparison. Clamped to
|
|
999
|
+
* [0.5, 2] and applied bidirectionally. When omitted, no correction is applied
|
|
1000
|
+
* (the confounded raw promptTokens/estimatedTokens quotient is never used).
|
|
1001
|
+
*/
|
|
1002
|
+
tokenCorrectionRatio?: number;
|
|
937
1003
|
}
|
|
938
1004
|
|
|
939
1005
|
export function prepareCompaction(
|
|
940
1006
|
pathEntries: SessionEntry[],
|
|
941
1007
|
settings: CompactionSettings,
|
|
1008
|
+
options: PrepareCompactionOptions = {},
|
|
942
1009
|
): CompactionPreparation | undefined {
|
|
943
1010
|
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
|
|
944
1011
|
return undefined;
|
|
@@ -956,17 +1023,22 @@ export function prepareCompaction(
|
|
|
956
1023
|
|
|
957
1024
|
const lastUsage = getLastAssistantUsage(pathEntries);
|
|
958
1025
|
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
|
|
959
|
-
let keepRecentTokens = settings.keepRecentTokens;
|
|
960
|
-
if (lastUsage) {
|
|
961
|
-
const estimatedTokens = estimateEntriesTokens(pathEntries, boundaryStart, boundaryEnd);
|
|
962
|
-
const promptTokens = calculatePromptTokens(lastUsage);
|
|
963
|
-
const ratio = estimatedTokens > 0 ? promptTokens / estimatedTokens : 0;
|
|
964
|
-
if (Number.isFinite(ratio) && ratio > 1) {
|
|
965
|
-
keepRecentTokens = Math.max(1, Math.floor(keepRecentTokens / ratio));
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
1026
|
|
|
969
|
-
|
|
1027
|
+
// Correct the keep-window budget for the chars/4 heuristic error using the
|
|
1028
|
+
// caller-supplied observed ratio (actual/heuristic). The legacy raw
|
|
1029
|
+
// promptTokens/estimatedTokens quotient is intentionally NOT used: promptTokens
|
|
1030
|
+
// counts system+tools+full history while estimatedTokens counted only the
|
|
1031
|
+
// post-boundary slice, so it was confounded and only ever shrank the window.
|
|
1032
|
+
// Here the correction is bidirectional and clamped to [0.5, 2].
|
|
1033
|
+
const keepRecentTokens = settings.keepRecentTokens;
|
|
1034
|
+
const rawRatio = options.tokenCorrectionRatio;
|
|
1035
|
+
const appliedRatio =
|
|
1036
|
+
rawRatio !== undefined && Number.isFinite(rawRatio) && rawRatio > 0
|
|
1037
|
+
? Math.min(TOKEN_CORRECTION_MAX_RATIO, Math.max(TOKEN_CORRECTION_MIN_RATIO, rawRatio))
|
|
1038
|
+
: 1;
|
|
1039
|
+
const keepRecentTokensCorrected = Math.max(1, Math.round(keepRecentTokens / appliedRatio));
|
|
1040
|
+
|
|
1041
|
+
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, keepRecentTokensCorrected);
|
|
970
1042
|
|
|
971
1043
|
// Get ID of first kept entry
|
|
972
1044
|
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
|
|
@@ -1034,6 +1106,7 @@ export function prepareCompaction(
|
|
|
1034
1106
|
previousPreserveData,
|
|
1035
1107
|
fileOps,
|
|
1036
1108
|
settings,
|
|
1109
|
+
tokenCorrection: { ratio: appliedRatio, keepRecentTokensCorrected },
|
|
1037
1110
|
};
|
|
1038
1111
|
}
|
|
1039
1112
|
|
|
@@ -1235,7 +1308,7 @@ async function generateTurnPrefixSummary(
|
|
|
1235
1308
|
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
|
|
1236
1309
|
|
|
1237
1310
|
const llmMessages = (options?.convertToLlm ?? convertToLlm)(messages);
|
|
1238
|
-
const conversationText = serializeConversation(llmMessages);
|
|
1311
|
+
const conversationText = boundConversationTextForSummary(serializeConversation(llmMessages), model, maxTokens);
|
|
1239
1312
|
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
|
1240
1313
|
const summarizationMessages = [
|
|
1241
1314
|
{
|
|
@@ -527,14 +527,29 @@ export function pruneAssistantToolArguments(
|
|
|
527
527
|
return { argumentPrunedCount: candidates.length, argumentTokensSaved, prunedEntries };
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
-
|
|
530
|
+
interface ToolOutputPruneCandidate {
|
|
531
|
+
entry: SessionMessageEntry;
|
|
532
|
+
tokens: number;
|
|
533
|
+
notice: string;
|
|
534
|
+
savings: number;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Read-only pass that collects the tool-result entries that {@link pruneToolOutputs}
|
|
539
|
+
* would prune, plus the total estimated token savings. Shared by the mutating
|
|
540
|
+
* prune and the non-mutating {@link estimateToolOutputPruneSavings} so the
|
|
541
|
+
* maintenance gate (Finding 13) can decide whether pruning is worth a cache-epoch
|
|
542
|
+
* reset without rewriting history.
|
|
543
|
+
*/
|
|
544
|
+
function collectToolOutputPruneCandidates(
|
|
545
|
+
entries: SessionEntry[],
|
|
546
|
+
config: PruneConfig,
|
|
547
|
+
): { candidates: ToolOutputPruneCandidate[]; tokensSaved: number } {
|
|
531
548
|
let accumulatedTokens = 0;
|
|
532
|
-
let tokensSaved = 0;
|
|
533
|
-
let prunedCount = 0;
|
|
534
549
|
|
|
535
550
|
const { staleResultIndices } = buildStalenessIndex(entries);
|
|
536
551
|
const staleOverridable = new Set(config.staleOverridableTools ?? []);
|
|
537
|
-
const candidates:
|
|
552
|
+
const candidates: ToolOutputPruneCandidate[] = [];
|
|
538
553
|
|
|
539
554
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
540
555
|
const entry = entries[i];
|
|
@@ -574,14 +589,56 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
|
|
|
574
589
|
accumulatedTokens += tokens;
|
|
575
590
|
}
|
|
576
591
|
|
|
592
|
+
let tokensSaved = 0;
|
|
577
593
|
for (const candidate of candidates) {
|
|
578
594
|
tokensSaved += candidate.savings;
|
|
579
595
|
}
|
|
596
|
+
return { candidates, tokensSaved };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Estimate the token savings {@link pruneToolOutputs} would achieve, without
|
|
601
|
+
* mutating any entry. Returns 0 savings when below the configured minimum so the
|
|
602
|
+
* caller sees the same gate the real prune enforces.
|
|
603
|
+
*/
|
|
604
|
+
export function estimateToolOutputPruneSavings(
|
|
605
|
+
entries: SessionEntry[],
|
|
606
|
+
config: PruneConfig = DEFAULT_PRUNE_CONFIG,
|
|
607
|
+
): { prunableCount: number; tokensSaved: number } {
|
|
608
|
+
const { candidates, tokensSaved } = collectToolOutputPruneCandidates(entries, config);
|
|
609
|
+
if (tokensSaved < config.minimumSavings || candidates.length === 0) {
|
|
610
|
+
return { prunableCount: 0, tokensSaved: 0 };
|
|
611
|
+
}
|
|
612
|
+
return { prunableCount: candidates.length, tokensSaved };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Evidence gate for below-threshold maintenance pruning (Finding 13). Pruning
|
|
617
|
+
* forces a prompt-cache-epoch reset, so it only runs when opted in AND the
|
|
618
|
+
* estimated stale savings clear a high minimum AND exceed the one-time reset
|
|
619
|
+
* cost (so the reclaim pays the reset back). Default-off/blocked until live
|
|
620
|
+
* evidence justifies enabling.
|
|
621
|
+
*/
|
|
622
|
+
export function shouldRunMaintenancePrune(args: {
|
|
623
|
+
enabled: boolean;
|
|
624
|
+
estimatedSavings: number;
|
|
625
|
+
minSavings: number;
|
|
626
|
+
cacheEpochResetCost: number;
|
|
627
|
+
}): boolean {
|
|
628
|
+
if (!args.enabled) return false;
|
|
629
|
+
if (args.estimatedSavings < args.minSavings) return false;
|
|
630
|
+
return args.estimatedSavings > args.cacheEpochResetCost;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
|
|
634
|
+
const { candidates, tokensSaved } = collectToolOutputPruneCandidates(entries, config);
|
|
580
635
|
|
|
581
636
|
if (tokensSaved < config.minimumSavings || candidates.length === 0) {
|
|
582
637
|
return { prunedCount: 0, tokensSaved: 0, prunedEntries: [] };
|
|
583
638
|
}
|
|
584
639
|
|
|
640
|
+
let prunedCount = 0;
|
|
641
|
+
|
|
585
642
|
const prunedAt = Date.now();
|
|
586
643
|
const prunedEntries: SessionMessageEntry[] = [];
|
|
587
644
|
for (const candidate of candidates) {
|