@sayknow-cli/agent-core 0.5.2 → 0.5.8
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 +13 -1
- package/dist/types/agent-loop.d.ts +11 -2
- package/dist/types/compaction/adaptive.d.ts +50 -0
- package/dist/types/compaction/compaction.d.ts +19 -3
- package/dist/types/compaction/index.d.ts +1 -0
- package/dist/types/types.d.ts +7 -0
- package/package.json +4 -4
- package/src/agent-loop.ts +72 -1
- package/src/compaction/adaptive.ts +116 -0
- package/src/compaction/compaction.ts +99 -4
- package/src/compaction/index.ts +1 -0
- package/src/compaction/utils.ts +6 -2
- package/src/types.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [0.
|
|
5
|
+
## [0.5.8] - 2026-09-10
|
|
6
|
+
|
|
7
|
+
## [0.5.7] - 2026-09-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Opt-in adaptive compaction. A fixed threshold compacts at the same context percentage no matter how fast a session fills its window, so a tool-call burst can overshoot between two checks while a quiet session compacts more often than it needs to. `AdaptiveCompactionTracker` records calls per window and `computeAdaptiveThresholdPercent` lowers the threshold in proportion to that rate, bounded by a configurable floor. Disabled by default and inert when disabled: a fixed `thresholdTokens` still wins, `thresholdPercent` is returned unchanged, and the reserve-based default path is untouched. When enabled it still returns the base while the context sits below 70% of it, and during a short post-compaction grace, so a burst cannot chain compactions.
|
|
12
|
+
|
|
13
|
+
## [0.5.3] - 2026-08-28
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Compaction now serializes malformed persisted tool calls with null or missing arguments instead of throwing inside the recovery path, and long managed sessions trigger an emergency rewrite before their append-only transcript reaches the storage file-size ceiling.
|
|
6
18
|
|
|
7
19
|
## [0.11.11] - 2026-07-26
|
|
8
20
|
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Agent loop that works with AgentMessage throughout.
|
|
3
3
|
* Transforms to Message[] only at the LLM call boundary.
|
|
4
4
|
*/
|
|
5
|
-
import { type Context, EventStream } from "@sayknow-cli/ai";
|
|
5
|
+
import { type AssistantMessage, type Context, EventStream, type TSchema } from "@sayknow-cli/ai";
|
|
6
6
|
import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector";
|
|
7
|
-
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types";
|
|
7
|
+
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool, StreamFn } from "./types";
|
|
8
8
|
/** Sentinel returned by the abort race in `streamAssistantResponse`. */
|
|
9
9
|
/**
|
|
10
10
|
* Defensive caps for a provisional managed attempt. These are intentionally
|
|
@@ -13,6 +13,15 @@ import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn
|
|
|
13
13
|
*/
|
|
14
14
|
export declare const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10000;
|
|
15
15
|
export declare const MANAGED_ATTEMPT_MAX_STAGED_BYTES: number;
|
|
16
|
+
/**
|
|
17
|
+
* Validate provider-observed escaped arguments before permitting the only
|
|
18
|
+
* exception to the fail-closed tool boundary. The raw JSON must decode to the
|
|
19
|
+
* exact parsed arguments, every decoded non-ASCII scalar must be under a
|
|
20
|
+
* tool-declared display path, and the wire must not contain ASCII escapes.
|
|
21
|
+
*/
|
|
22
|
+
export declare function displaySafeEscapedArguments(tool: AgentTool<TSchema> | undefined, toolCall: Extract<AssistantMessage["content"][number], {
|
|
23
|
+
type: "toolCall";
|
|
24
|
+
}>): boolean;
|
|
16
25
|
/**
|
|
17
26
|
* Start an agent loop with a new prompt message.
|
|
18
27
|
* The prompt is added to the context and events are emitted for it.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive compaction state.
|
|
3
|
+
*
|
|
4
|
+
* A fixed threshold compacts at the same context percentage regardless of how
|
|
5
|
+
* fast the session is filling the window. During a tool-call burst the context
|
|
6
|
+
* can jump well past the threshold between two checks, while a slow session
|
|
7
|
+
* compacts more often than it needs to. The tracker records call rate per
|
|
8
|
+
* window so {@link ../compaction!computeAdaptiveThresholdPercent} can lower the
|
|
9
|
+
* threshold while a session is busy and leave it alone otherwise.
|
|
10
|
+
*
|
|
11
|
+
* Opt-in: with `compaction.adaptive.enabled` false the tracker is still cheap to
|
|
12
|
+
* run but its state never reaches a threshold decision.
|
|
13
|
+
*/
|
|
14
|
+
export interface AdaptiveCompactionState {
|
|
15
|
+
turnsSinceCompact: number;
|
|
16
|
+
callsInWindow: number;
|
|
17
|
+
windowStart: number;
|
|
18
|
+
lastContextTokens: number;
|
|
19
|
+
lastCompactContextTokens: number | null;
|
|
20
|
+
lastCompactTs: number | null;
|
|
21
|
+
}
|
|
22
|
+
/** The subset a threshold decision reads; keeps the decision path pure. */
|
|
23
|
+
export interface AdaptiveCompactionDecisionState {
|
|
24
|
+
turnsSinceCompact: number;
|
|
25
|
+
callsInWindow: number;
|
|
26
|
+
lastContextTokens?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface AdaptiveCompactionOptions {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
/** Minutes of recent calls considered when measuring call rate. */
|
|
31
|
+
turnWindow: number;
|
|
32
|
+
/** Context percentage used when the session is not busy. */
|
|
33
|
+
baseThresholdPercent: number;
|
|
34
|
+
/** How strongly call rate lowers the threshold, 0 to 1. */
|
|
35
|
+
aggression: number;
|
|
36
|
+
/** Lowest percentage the threshold may be lowered to. */
|
|
37
|
+
minThresholdPercent?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare class AdaptiveCompactionTracker {
|
|
40
|
+
#private;
|
|
41
|
+
constructor(windowMs?: number, now?: number);
|
|
42
|
+
get windowMs(): number;
|
|
43
|
+
/** Changing the window restarts the current count so a resize cannot inherit a rate measured over a different span. */
|
|
44
|
+
setWindowMs(windowMs: number, now?: number): void;
|
|
45
|
+
reset(now?: number): void;
|
|
46
|
+
recordCall(contextTokens: number, now?: number): void;
|
|
47
|
+
recordCompact(contextTokens: number, now?: number): void;
|
|
48
|
+
snapshot(): AdaptiveCompactionState;
|
|
49
|
+
decisionState(): AdaptiveCompactionDecisionState;
|
|
50
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { type MessageAttribution, type Model, type ProviderSessionState, type Usage } from "@sayknow-cli/ai";
|
|
8
8
|
import { type AgentTelemetry } from "../telemetry";
|
|
9
9
|
import type { AgentMessage, AgentTool } from "../types";
|
|
10
|
+
import type { AdaptiveCompactionDecisionState, AdaptiveCompactionOptions } from "./adaptive";
|
|
10
11
|
import type { SessionEntry } from "./entries";
|
|
11
12
|
import { type ConvertToLlm } from "./messages";
|
|
12
13
|
import { type FileOperations } from "./utils";
|
|
@@ -32,6 +33,10 @@ export interface CompactionSettings {
|
|
|
32
33
|
strategy?: "context-full" | "handoff" | "off";
|
|
33
34
|
thresholdPercent?: number;
|
|
34
35
|
thresholdTokens?: number;
|
|
36
|
+
/** Opt-in adaptive threshold controls. Absent or disabled keeps the fixed threshold. */
|
|
37
|
+
adaptive?: AdaptiveCompactionOptions;
|
|
38
|
+
/** Call-rate state a threshold decision reads; supplied by the session's tracker. */
|
|
39
|
+
adaptiveState?: AdaptiveCompactionDecisionState;
|
|
35
40
|
reserveTokens: number;
|
|
36
41
|
keepRecentTokens: number;
|
|
37
42
|
autoContinue?: boolean;
|
|
@@ -80,7 +85,7 @@ export declare function effectiveReserveTokens(contextWindow: number, settings:
|
|
|
80
85
|
*/
|
|
81
86
|
export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): boolean;
|
|
82
87
|
/** Reason a compaction was triggered. `token` is the normal user-configurable path; the rest are emergency floors. */
|
|
83
|
-
export type CompactionTriggerReason = "token" | "heap" | "retainedMemory" | "providerBytes" | "messageCount" | "imageBytes";
|
|
88
|
+
export type CompactionTriggerReason = "token" | "heap" | "retainedMemory" | "transcriptFile" | "providerBytes" | "messageCount" | "imageBytes";
|
|
84
89
|
/** A point-in-time resource sample. Supplied by an injectable sampler so tests never read real RSS. */
|
|
85
90
|
export interface EmergencyCompactionSample {
|
|
86
91
|
/** Resident heap bytes (e.g. process.memoryUsage().heapUsed). */
|
|
@@ -99,6 +104,8 @@ export interface EmergencyCompactionSample {
|
|
|
99
104
|
tuiChatChildren?: number;
|
|
100
105
|
/** Bytes retained by TUI render caches. */
|
|
101
106
|
tuiCachedRenderBytes?: number;
|
|
107
|
+
/** On-disk JSONL transcript file size in bytes; 0/undefined when unknown. */
|
|
108
|
+
transcriptFileBytes?: number;
|
|
102
109
|
}
|
|
103
110
|
export interface EmergencyCompactionLimits {
|
|
104
111
|
heapUsedBytes: number;
|
|
@@ -109,6 +116,7 @@ export interface EmergencyCompactionLimits {
|
|
|
109
116
|
retainedMemoryDiagnosticBytes?: number;
|
|
110
117
|
tuiChatChildren?: number;
|
|
111
118
|
tuiChatChildrenDiagnostic?: number;
|
|
119
|
+
transcriptFileBytes?: number;
|
|
112
120
|
}
|
|
113
121
|
export declare function resetEmergencyRetainedMemoryDiagnosticsForTests(): void;
|
|
114
122
|
export declare function resolveEmergencyCompactionLimits(totalMemoryBytes?: number): EmergencyCompactionLimits;
|
|
@@ -119,12 +127,20 @@ export declare function resolveEmergencyCompactionLimits(totalMemoryBytes?: numb
|
|
|
119
127
|
*/
|
|
120
128
|
export declare const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits;
|
|
121
129
|
/**
|
|
122
|
-
* Returns the first emergency limit exceeded (heap > retainedMemory > providerBytes > imageBytes > messageCount),
|
|
130
|
+
* Returns the first emergency limit exceeded (heap > retainedMemory > transcriptFile > providerBytes > imageBytes > messageCount),
|
|
123
131
|
* or null when none is. Pure apart from retained-memory diagnostics; the caller routes the result through the
|
|
124
132
|
* normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
|
|
125
133
|
*/
|
|
126
134
|
export declare function emergencyCompactionReason(sample: EmergencyCompactionSample, limits?: EmergencyCompactionLimits): CompactionTriggerReason | null;
|
|
127
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Lower the compaction threshold while a session is filling its window quickly.
|
|
137
|
+
*
|
|
138
|
+
* Returns `basePercent` untouched unless adaptive mode is enabled and the session
|
|
139
|
+
* is both near the base threshold and past a short post-compaction grace, so a
|
|
140
|
+
* quiet or just-compacted session keeps the fixed behavior exactly.
|
|
141
|
+
*/
|
|
142
|
+
export declare function computeAdaptiveThresholdPercent(basePercent: number, contextTokens: number, contextWindow: number, state: AdaptiveCompactionDecisionState | undefined, options: AdaptiveCompactionOptions | undefined): number;
|
|
143
|
+
export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number, contextTokens?: number): number;
|
|
128
144
|
/**
|
|
129
145
|
* Image content has no tokenizer representation; charge a fixed estimate
|
|
130
146
|
* matching what providers typically bill for inline images.
|
package/dist/types/types.d.ts
CHANGED
|
@@ -461,6 +461,13 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
|
|
|
461
461
|
* - function: `_i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
|
|
462
462
|
*/
|
|
463
463
|
intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
|
|
464
|
+
/**
|
|
465
|
+
* Argument fields (dotted paths into the arguments object) that render as
|
|
466
|
+
* pure display text. A corroborated `\uXXXX`-escaped non-ASCII payload may
|
|
467
|
+
* execute with a warning only when every decoded non-ASCII value is under
|
|
468
|
+
* one of these paths. IDs, metadata, and all undeclared fields fail closed.
|
|
469
|
+
*/
|
|
470
|
+
displaySafeEscapedArgFields?: readonly string[];
|
|
464
471
|
/** The main execution callback for this tool. */
|
|
465
472
|
execute: AgentToolExecFn<TParameters, TDetails, TTheme>;
|
|
466
473
|
/** Optional custom rendering for tool call display (returns UI component) */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/agent-core",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.8",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@sayknow-cli/ai": "0.5.
|
|
39
|
-
"@sayknow-cli/natives": "0.5.
|
|
40
|
-
"@sayknow-cli/utils": "0.5.
|
|
38
|
+
"@sayknow-cli/ai": "0.5.8",
|
|
39
|
+
"@sayknow-cli/natives": "0.5.8",
|
|
40
|
+
"@sayknow-cli/utils": "0.5.8",
|
|
41
41
|
"@opentelemetry/api": "^1.9.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
zodToWireSchema,
|
|
22
22
|
} from "@sayknow-cli/ai";
|
|
23
23
|
import { isInvalidPromptError, neutralizeReservedControlTokens } from "@sayknow-cli/ai/utils";
|
|
24
|
-
import { sanitizeText } from "@sayknow-cli/utils";
|
|
24
|
+
import { logger, sanitizeText } from "@sayknow-cli/utils";
|
|
25
25
|
import {
|
|
26
26
|
createHarmonyAuditEvent,
|
|
27
27
|
detectHarmonyLeakInAssistantMessage,
|
|
@@ -113,6 +113,62 @@ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
|
|
|
113
113
|
* bounded too.
|
|
114
114
|
*/
|
|
115
115
|
const MAX_CONSECUTIVE_MALFORMED_TURNS = 5;
|
|
116
|
+
/**
|
|
117
|
+
* Validate provider-observed escaped arguments before permitting the only
|
|
118
|
+
* exception to the fail-closed tool boundary. The raw JSON must decode to the
|
|
119
|
+
* exact parsed arguments, every decoded non-ASCII scalar must be under a
|
|
120
|
+
* tool-declared display path, and the wire must not contain ASCII escapes.
|
|
121
|
+
*/
|
|
122
|
+
export function displaySafeEscapedArguments(
|
|
123
|
+
tool: AgentTool<TSchema> | undefined,
|
|
124
|
+
toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
|
|
125
|
+
): boolean {
|
|
126
|
+
const fields = tool?.displaySafeEscapedArgFields;
|
|
127
|
+
const raw = toolCall.escapedNonAsciiArgumentsRaw;
|
|
128
|
+
if (!fields?.length || !raw) return false;
|
|
129
|
+
|
|
130
|
+
let decoded: unknown;
|
|
131
|
+
try {
|
|
132
|
+
decoded = JSON.parse(raw);
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
if (JSON.stringify(decoded) !== JSON.stringify(toolCall.arguments)) return false;
|
|
137
|
+
|
|
138
|
+
const displayPaths = fields.map(field => field.split("."));
|
|
139
|
+
const path: string[] = [];
|
|
140
|
+
const isDisplayPath = () =>
|
|
141
|
+
displayPaths.some(
|
|
142
|
+
segments => segments.length === path.length && segments.every((segment, index) => path[index] === segment),
|
|
143
|
+
);
|
|
144
|
+
const walk = (value: unknown): boolean => {
|
|
145
|
+
if (typeof value === "string") {
|
|
146
|
+
return [...value].every(character => character.codePointAt(0)! < 0x80 || isDisplayPath());
|
|
147
|
+
}
|
|
148
|
+
if (Array.isArray(value)) return value.every(walk);
|
|
149
|
+
if (value && typeof value === "object") {
|
|
150
|
+
return Object.entries(value).every(([key, child]) => {
|
|
151
|
+
if (!/^[\x00-\x7f]*$/.test(key)) return false;
|
|
152
|
+
path.push(key);
|
|
153
|
+
const valid = walk(child);
|
|
154
|
+
path.pop();
|
|
155
|
+
return valid;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
};
|
|
160
|
+
if (!walk(toolCall.arguments)) return false;
|
|
161
|
+
|
|
162
|
+
const escapes = [...raw.matchAll(/\\u([0-9a-fA-F]{4})/g)].map(match => Number.parseInt(match[1]!, 16));
|
|
163
|
+
return escapes.length > 0 && escapes.every(codeUnit => codeUnit >= 0x80);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function clearEscapedArgumentMetadata(
|
|
167
|
+
toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
|
|
168
|
+
): void {
|
|
169
|
+
delete toolCall.escapedNonAsciiArguments;
|
|
170
|
+
delete toolCall.escapedNonAsciiArgumentsRaw;
|
|
171
|
+
}
|
|
116
172
|
function managedContextOverflow(message: AssistantMessage, config: AgentLoopConfig): boolean {
|
|
117
173
|
const transportFailure = managedTransportFailure(message);
|
|
118
174
|
// Managed empty-stop responses may be repaired by the managed shell below; only
|
|
@@ -1541,6 +1597,7 @@ async function runLoopBody(
|
|
|
1541
1597
|
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
1542
1598
|
const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
|
|
1543
1599
|
const toolResults: ToolResultMessage[] = [];
|
|
1600
|
+
for (const toolCall of toolCalls) clearEscapedArgumentMetadata(toolCall);
|
|
1544
1601
|
for (const toolCall of toolCalls) {
|
|
1545
1602
|
const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
|
|
1546
1603
|
currentContext.messages.push(result);
|
|
@@ -1571,6 +1628,7 @@ async function runLoopBody(
|
|
|
1571
1628
|
let repeatedMalformedToolCall = false;
|
|
1572
1629
|
if (hasMoreToolCalls) {
|
|
1573
1630
|
if (wasRecoveryAttempt) {
|
|
1631
|
+
for (const toolCall of toolCalls) clearEscapedArgumentMetadata(toolCall);
|
|
1574
1632
|
for (const toolCall of toolCalls) {
|
|
1575
1633
|
const result = createAbortedToolResult(
|
|
1576
1634
|
toolCall,
|
|
@@ -2195,6 +2253,19 @@ async function executeToolCalls(
|
|
|
2195
2253
|
|
|
2196
2254
|
await runInActiveSpan(toolSpan, async () => {
|
|
2197
2255
|
try {
|
|
2256
|
+
if (toolCall.escapedNonAsciiArguments) {
|
|
2257
|
+
const displaySafe = displaySafeEscapedArguments(tool, toolCall);
|
|
2258
|
+
clearEscapedArgumentMetadata(toolCall);
|
|
2259
|
+
if (!displaySafe) {
|
|
2260
|
+
record.argumentValidationFailed = true;
|
|
2261
|
+
throw new Error(
|
|
2262
|
+
"Tool call arguments contained unverified \\uXXXX-escaped non-ASCII text and were rejected.",
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
logger.warn("agent: executing a tool-call whose display-safe arguments were \\uXXXX-escaped", {
|
|
2266
|
+
mode: config.fallbackManaged ? "managed" : "in_loop",
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2198
2269
|
if (toolCall.incompleteArguments) {
|
|
2199
2270
|
record.argumentValidationFailed = true;
|
|
2200
2271
|
// The provider flagged this call's argument JSON as truncated
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive compaction state.
|
|
3
|
+
*
|
|
4
|
+
* A fixed threshold compacts at the same context percentage regardless of how
|
|
5
|
+
* fast the session is filling the window. During a tool-call burst the context
|
|
6
|
+
* can jump well past the threshold between two checks, while a slow session
|
|
7
|
+
* compacts more often than it needs to. The tracker records call rate per
|
|
8
|
+
* window so {@link ../compaction!computeAdaptiveThresholdPercent} can lower the
|
|
9
|
+
* threshold while a session is busy and leave it alone otherwise.
|
|
10
|
+
*
|
|
11
|
+
* Opt-in: with `compaction.adaptive.enabled` false the tracker is still cheap to
|
|
12
|
+
* run but its state never reaches a threshold decision.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface AdaptiveCompactionState {
|
|
16
|
+
turnsSinceCompact: number;
|
|
17
|
+
callsInWindow: number;
|
|
18
|
+
windowStart: number;
|
|
19
|
+
lastContextTokens: number;
|
|
20
|
+
lastCompactContextTokens: number | null;
|
|
21
|
+
lastCompactTs: number | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The subset a threshold decision reads; keeps the decision path pure. */
|
|
25
|
+
export interface AdaptiveCompactionDecisionState {
|
|
26
|
+
turnsSinceCompact: number;
|
|
27
|
+
callsInWindow: number;
|
|
28
|
+
lastContextTokens?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AdaptiveCompactionOptions {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
/** Minutes of recent calls considered when measuring call rate. */
|
|
34
|
+
turnWindow: number;
|
|
35
|
+
/** Context percentage used when the session is not busy. */
|
|
36
|
+
baseThresholdPercent: number;
|
|
37
|
+
/** How strongly call rate lowers the threshold, 0 to 1. */
|
|
38
|
+
aggression: number;
|
|
39
|
+
/** Lowest percentage the threshold may be lowered to. */
|
|
40
|
+
minThresholdPercent?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_WINDOW_MS = 60_000;
|
|
44
|
+
|
|
45
|
+
function initialState(now: number): AdaptiveCompactionState {
|
|
46
|
+
return {
|
|
47
|
+
turnsSinceCompact: 0,
|
|
48
|
+
callsInWindow: 0,
|
|
49
|
+
windowStart: now,
|
|
50
|
+
lastContextTokens: 0,
|
|
51
|
+
lastCompactContextTokens: null,
|
|
52
|
+
lastCompactTs: null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class AdaptiveCompactionTracker {
|
|
57
|
+
#state: AdaptiveCompactionState;
|
|
58
|
+
#windowMs: number;
|
|
59
|
+
|
|
60
|
+
constructor(windowMs: number = DEFAULT_WINDOW_MS, now: number = Date.now()) {
|
|
61
|
+
this.#windowMs = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : DEFAULT_WINDOW_MS;
|
|
62
|
+
this.#state = initialState(now);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get windowMs(): number {
|
|
66
|
+
return this.#windowMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Changing the window restarts the current count so a resize cannot inherit a rate measured over a different span. */
|
|
70
|
+
setWindowMs(windowMs: number, now: number = Date.now()): void {
|
|
71
|
+
if (!Number.isFinite(windowMs)) return;
|
|
72
|
+
const nextWindowMs = Math.max(1, windowMs);
|
|
73
|
+
if (nextWindowMs === this.#windowMs) return;
|
|
74
|
+
this.#windowMs = nextWindowMs;
|
|
75
|
+
this.#state.windowStart = now;
|
|
76
|
+
this.#state.callsInWindow = 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
reset(now: number = Date.now()): void {
|
|
80
|
+
this.#state = initialState(now);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
recordCall(contextTokens: number, now: number = Date.now()): void {
|
|
84
|
+
const timestamp = Number.isFinite(now) ? now : Date.now();
|
|
85
|
+
this.#state.turnsSinceCompact += 1;
|
|
86
|
+
if (timestamp - this.#state.windowStart >= this.#windowMs) {
|
|
87
|
+
this.#state.windowStart = timestamp;
|
|
88
|
+
this.#state.callsInWindow = 0;
|
|
89
|
+
}
|
|
90
|
+
this.#state.callsInWindow += 1;
|
|
91
|
+
this.#state.lastContextTokens = Number.isFinite(contextTokens) ? Math.max(0, contextTokens) : 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
recordCompact(contextTokens: number, now: number = Date.now()): void {
|
|
95
|
+
const timestamp = Number.isFinite(now) ? now : Date.now();
|
|
96
|
+
const safeContextTokens = Number.isFinite(contextTokens) ? Math.max(0, contextTokens) : 0;
|
|
97
|
+
this.#state.turnsSinceCompact = 0;
|
|
98
|
+
this.#state.callsInWindow = 0;
|
|
99
|
+
this.#state.windowStart = timestamp;
|
|
100
|
+
this.#state.lastContextTokens = safeContextTokens;
|
|
101
|
+
this.#state.lastCompactContextTokens = safeContextTokens;
|
|
102
|
+
this.#state.lastCompactTs = timestamp;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
snapshot(): AdaptiveCompactionState {
|
|
106
|
+
return { ...this.#state };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
decisionState(): AdaptiveCompactionDecisionState {
|
|
110
|
+
return {
|
|
111
|
+
turnsSinceCompact: this.#state.turnsSinceCompact,
|
|
112
|
+
callsInWindow: this.#state.callsInWindow,
|
|
113
|
+
lastContextTokens: this.#state.lastContextTokens,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { logger, prompt } from "@sayknow-cli/utils";
|
|
19
19
|
import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
|
|
20
20
|
import type { AgentMessage, AgentTool } from "../types";
|
|
21
|
+
import type { AdaptiveCompactionDecisionState, AdaptiveCompactionOptions } from "./adaptive";
|
|
21
22
|
import type { CompactionEntry, SessionEntry } from "./entries";
|
|
22
23
|
import { type ConvertToLlm, convertToLlm, createBranchSummaryMessage, createCustomMessage } from "./messages";
|
|
23
24
|
import {
|
|
@@ -136,6 +137,10 @@ export interface CompactionSettings {
|
|
|
136
137
|
strategy?: "context-full" | "handoff" | "off";
|
|
137
138
|
thresholdPercent?: number;
|
|
138
139
|
thresholdTokens?: number;
|
|
140
|
+
/** Opt-in adaptive threshold controls. Absent or disabled keeps the fixed threshold. */
|
|
141
|
+
adaptive?: AdaptiveCompactionOptions;
|
|
142
|
+
/** Call-rate state a threshold decision reads; supplied by the session's tracker. */
|
|
143
|
+
adaptiveState?: AdaptiveCompactionDecisionState;
|
|
139
144
|
reserveTokens: number;
|
|
140
145
|
keepRecentTokens: number;
|
|
141
146
|
autoContinue?: boolean;
|
|
@@ -244,7 +249,7 @@ export function shouldCompact(
|
|
|
244
249
|
maxOutputTokens = 0,
|
|
245
250
|
): boolean {
|
|
246
251
|
if (!settings.enabled || settings.strategy === "off" || contextWindow <= 0) return false;
|
|
247
|
-
const thresholdTokens = resolveThresholdTokens(contextWindow, settings, maxOutputTokens);
|
|
252
|
+
const thresholdTokens = resolveThresholdTokens(contextWindow, settings, maxOutputTokens, contextTokens);
|
|
248
253
|
return contextTokens > thresholdTokens;
|
|
249
254
|
}
|
|
250
255
|
|
|
@@ -253,6 +258,7 @@ export type CompactionTriggerReason =
|
|
|
253
258
|
| "token"
|
|
254
259
|
| "heap"
|
|
255
260
|
| "retainedMemory"
|
|
261
|
+
| "transcriptFile"
|
|
256
262
|
| "providerBytes"
|
|
257
263
|
| "messageCount"
|
|
258
264
|
| "imageBytes";
|
|
@@ -275,6 +281,8 @@ export interface EmergencyCompactionSample {
|
|
|
275
281
|
tuiChatChildren?: number;
|
|
276
282
|
/** Bytes retained by TUI render caches. */
|
|
277
283
|
tuiCachedRenderBytes?: number;
|
|
284
|
+
/** On-disk JSONL transcript file size in bytes; 0/undefined when unknown. */
|
|
285
|
+
transcriptFileBytes?: number;
|
|
278
286
|
}
|
|
279
287
|
|
|
280
288
|
export interface EmergencyCompactionLimits {
|
|
@@ -286,6 +294,7 @@ export interface EmergencyCompactionLimits {
|
|
|
286
294
|
retainedMemoryDiagnosticBytes?: number;
|
|
287
295
|
tuiChatChildren?: number;
|
|
288
296
|
tuiChatChildrenDiagnostic?: number;
|
|
297
|
+
transcriptFileBytes?: number;
|
|
289
298
|
}
|
|
290
299
|
|
|
291
300
|
const MAX_EMERGENCY_HEAP_FLOOR_BYTES = 1_536 * 1024 * 1024; // 1.5 GiB resident heap
|
|
@@ -293,6 +302,7 @@ const EMERGENCY_RETAINED_MEMORY_BYTES = 128 * 1024 * 1024;
|
|
|
293
302
|
const DIAGNOSTIC_RETAINED_MEMORY_BYTES = 64 * 1024 * 1024;
|
|
294
303
|
const EMERGENCY_TUI_CHAT_CHILDREN = 1000;
|
|
295
304
|
const DIAGNOSTIC_TUI_CHAT_CHILDREN = 700;
|
|
305
|
+
const EMERGENCY_TRANSCRIPT_FILE_BYTES = 48 * 1024 * 1024; // 48 MiB (75% of the 64 MiB managed cap)
|
|
296
306
|
let retainedMemoryDiagnosticActive = false;
|
|
297
307
|
let tuiChatChildrenDiagnosticActive = false;
|
|
298
308
|
|
|
@@ -315,6 +325,7 @@ export function resolveEmergencyCompactionLimits(totalMemoryBytes: number = os.t
|
|
|
315
325
|
retainedMemoryDiagnosticBytes: DIAGNOSTIC_RETAINED_MEMORY_BYTES,
|
|
316
326
|
tuiChatChildren: EMERGENCY_TUI_CHAT_CHILDREN,
|
|
317
327
|
tuiChatChildrenDiagnostic: DIAGNOSTIC_TUI_CHAT_CHILDREN,
|
|
328
|
+
transcriptFileBytes: EMERGENCY_TRANSCRIPT_FILE_BYTES,
|
|
318
329
|
};
|
|
319
330
|
}
|
|
320
331
|
|
|
@@ -326,7 +337,7 @@ export function resolveEmergencyCompactionLimits(totalMemoryBytes: number = os.t
|
|
|
326
337
|
export const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits = resolveEmergencyCompactionLimits();
|
|
327
338
|
|
|
328
339
|
/**
|
|
329
|
-
* Returns the first emergency limit exceeded (heap > retainedMemory > providerBytes > imageBytes > messageCount),
|
|
340
|
+
* Returns the first emergency limit exceeded (heap > retainedMemory > transcriptFile > providerBytes > imageBytes > messageCount),
|
|
330
341
|
* or null when none is. Pure apart from retained-memory diagnostics; the caller routes the result through the
|
|
331
342
|
* normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
|
|
332
343
|
*/
|
|
@@ -360,16 +371,74 @@ export function emergencyCompactionReason(
|
|
|
360
371
|
tuiChatChildren >= (limits.tuiChatChildren ?? EMERGENCY_TUI_CHAT_CHILDREN)
|
|
361
372
|
)
|
|
362
373
|
return "retainedMemory";
|
|
374
|
+
if (
|
|
375
|
+
sample.transcriptFileBytes &&
|
|
376
|
+
sample.transcriptFileBytes > (limits.transcriptFileBytes ?? EMERGENCY_TRANSCRIPT_FILE_BYTES)
|
|
377
|
+
)
|
|
378
|
+
return "transcriptFile";
|
|
363
379
|
if (sample.providerBytes > limits.providerBytes) return "providerBytes";
|
|
364
380
|
if (sample.imageBytes > limits.imageBytes) return "imageBytes";
|
|
365
381
|
if (sample.messageCount > limits.messageCount) return "messageCount";
|
|
366
382
|
return null;
|
|
367
383
|
}
|
|
368
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Lower the compaction threshold while a session is filling its window quickly.
|
|
387
|
+
*
|
|
388
|
+
* Returns `basePercent` untouched unless adaptive mode is enabled and the session
|
|
389
|
+
* is both near the base threshold and past a short post-compaction grace, so a
|
|
390
|
+
* quiet or just-compacted session keeps the fixed behavior exactly.
|
|
391
|
+
*/
|
|
392
|
+
export function computeAdaptiveThresholdPercent(
|
|
393
|
+
basePercent: number,
|
|
394
|
+
contextTokens: number,
|
|
395
|
+
contextWindow: number,
|
|
396
|
+
state: AdaptiveCompactionDecisionState | undefined,
|
|
397
|
+
options: AdaptiveCompactionOptions | undefined,
|
|
398
|
+
): number {
|
|
399
|
+
if (!options?.enabled) return basePercent;
|
|
400
|
+
const clampedBasePercent = Number.isFinite(basePercent) ? Math.min(99, Math.max(1, basePercent)) : 85;
|
|
401
|
+
if (!state || !Number.isFinite(contextWindow) || contextWindow <= 0) return clampedBasePercent;
|
|
402
|
+
if (!Number.isFinite(options.turnWindow) || options.turnWindow <= 0) return clampedBasePercent;
|
|
403
|
+
|
|
404
|
+
// Far from the base threshold there is nothing to bring forward.
|
|
405
|
+
const safeContextTokens = Number.isFinite(contextTokens) ? Math.max(0, contextTokens) : 0;
|
|
406
|
+
const fillRatio = safeContextTokens / contextWindow;
|
|
407
|
+
const baseRatio = clampedBasePercent / 100;
|
|
408
|
+
if (fillRatio < baseRatio * 0.7) return clampedBasePercent;
|
|
409
|
+
|
|
410
|
+
// Grace after a compaction so a burst cannot chain compactions back to back.
|
|
411
|
+
const turnsSinceCompact = Number.isFinite(state.turnsSinceCompact) ? Math.max(0, state.turnsSinceCompact) : 0;
|
|
412
|
+
if (turnsSinceCompact <= 3) return clampedBasePercent;
|
|
413
|
+
|
|
414
|
+
const callsInWindow = Number.isFinite(state.callsInWindow) ? Math.max(0, state.callsInWindow) : 0;
|
|
415
|
+
const windowTurns = Math.max(1, options.turnWindow * 4);
|
|
416
|
+
const intensity = Math.min(1, callsInWindow / windowTurns);
|
|
417
|
+
const aggression = Number.isFinite(options.aggression) ? Math.min(1, Math.max(0, options.aggression)) : 0;
|
|
418
|
+
const configuredMinThresholdPercent = options.minThresholdPercent;
|
|
419
|
+
const minThresholdPercent = Math.min(
|
|
420
|
+
clampedBasePercent,
|
|
421
|
+
typeof configuredMinThresholdPercent === "number" && Number.isFinite(configuredMinThresholdPercent)
|
|
422
|
+
? Math.max(1, configuredMinThresholdPercent)
|
|
423
|
+
: clampedBasePercent * 0.5,
|
|
424
|
+
);
|
|
425
|
+
const loweredPercent = clampedBasePercent - (clampedBasePercent - minThresholdPercent) * aggression * intensity;
|
|
426
|
+
return Math.max(1, Math.min(99, Math.round(loweredPercent)));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function adaptiveContextTokens(contextTokens: number | undefined, lastContextTokens: number | undefined): number {
|
|
430
|
+
if (contextTokens !== undefined && Number.isFinite(contextTokens)) return Math.max(0, contextTokens);
|
|
431
|
+
if (typeof lastContextTokens === "number" && Number.isFinite(lastContextTokens)) {
|
|
432
|
+
return Math.max(0, lastContextTokens);
|
|
433
|
+
}
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
369
437
|
export function resolveThresholdTokens(
|
|
370
438
|
contextWindow: number,
|
|
371
439
|
settings: CompactionSettings,
|
|
372
440
|
maxOutputTokens = 0,
|
|
441
|
+
contextTokens?: number,
|
|
373
442
|
): number {
|
|
374
443
|
// Fixed token limit takes priority over percentage
|
|
375
444
|
const thresholdTokens = settings.thresholdTokens;
|
|
@@ -378,13 +447,39 @@ export function resolveThresholdTokens(
|
|
|
378
447
|
return Math.min(contextWindow - 1, Math.max(1, thresholdTokens));
|
|
379
448
|
}
|
|
380
449
|
|
|
450
|
+
const effectiveContextTokens = adaptiveContextTokens(contextTokens, settings.adaptiveState?.lastContextTokens);
|
|
451
|
+
|
|
381
452
|
// Percentage-based threshold
|
|
382
453
|
const thresholdPercent = settings.thresholdPercent;
|
|
383
454
|
if (typeof thresholdPercent !== "number" || !Number.isFinite(thresholdPercent) || thresholdPercent <= 0) {
|
|
384
|
-
|
|
455
|
+
if (!settings.adaptive?.enabled) {
|
|
456
|
+
return contextWindow - effectiveReserveTokens(contextWindow, settings, maxOutputTokens);
|
|
457
|
+
}
|
|
458
|
+
// No configured percentage: adaptive supplies its own base.
|
|
459
|
+
const adaptiveBasePercent = Number.isFinite(settings.adaptive.baseThresholdPercent)
|
|
460
|
+
? Math.min(99, Math.max(1, settings.adaptive.baseThresholdPercent))
|
|
461
|
+
: 85;
|
|
462
|
+
const adaptiveThresholdPercent = computeAdaptiveThresholdPercent(
|
|
463
|
+
adaptiveBasePercent,
|
|
464
|
+
effectiveContextTokens,
|
|
465
|
+
contextWindow,
|
|
466
|
+
settings.adaptiveState,
|
|
467
|
+
settings.adaptive,
|
|
468
|
+
);
|
|
469
|
+
return Math.floor(contextWindow * (adaptiveThresholdPercent / 100));
|
|
385
470
|
}
|
|
386
471
|
const clampedThresholdPercent = Math.min(99, Math.max(1, thresholdPercent));
|
|
387
|
-
|
|
472
|
+
if (!settings.adaptive?.enabled) {
|
|
473
|
+
return Math.floor(contextWindow * (clampedThresholdPercent / 100));
|
|
474
|
+
}
|
|
475
|
+
const adaptiveThresholdPercent = computeAdaptiveThresholdPercent(
|
|
476
|
+
settings.adaptive.baseThresholdPercent ?? clampedThresholdPercent,
|
|
477
|
+
effectiveContextTokens,
|
|
478
|
+
contextWindow,
|
|
479
|
+
settings.adaptiveState,
|
|
480
|
+
settings.adaptive,
|
|
481
|
+
);
|
|
482
|
+
return Math.floor(contextWindow * (adaptiveThresholdPercent / 100));
|
|
388
483
|
}
|
|
389
484
|
|
|
390
485
|
// ============================================================================
|
package/src/compaction/index.ts
CHANGED
package/src/compaction/utils.ts
CHANGED
|
@@ -147,8 +147,12 @@ export function serializeConversation(messages: Message[]): string {
|
|
|
147
147
|
} else if (block.type === "thinking") {
|
|
148
148
|
thinkingParts.push(block.thinking);
|
|
149
149
|
} else if (block.type === "toolCall") {
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
// `arguments` is typed non-null, but persisted history can carry a
|
|
151
|
+
// null/non-object payload from an aborted or malformed tool call.
|
|
152
|
+
// Summarization must never throw here: this runs inside compaction,
|
|
153
|
+
// which is itself the recovery path for context overflow.
|
|
154
|
+
const args = block.arguments as Record<string, unknown> | null | undefined;
|
|
155
|
+
const argsStr = Object.entries(args && typeof args === "object" && !Array.isArray(args) ? args : {})
|
|
152
156
|
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
153
157
|
.join(", ");
|
|
154
158
|
toolCalls.push(`${block.name}(${argsStr})`);
|
package/src/types.ts
CHANGED
|
@@ -539,6 +539,13 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
|
|
|
539
539
|
* - function: `_i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
|
|
540
540
|
*/
|
|
541
541
|
intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
|
|
542
|
+
/**
|
|
543
|
+
* Argument fields (dotted paths into the arguments object) that render as
|
|
544
|
+
* pure display text. A corroborated `\uXXXX`-escaped non-ASCII payload may
|
|
545
|
+
* execute with a warning only when every decoded non-ASCII value is under
|
|
546
|
+
* one of these paths. IDs, metadata, and all undeclared fields fail closed.
|
|
547
|
+
*/
|
|
548
|
+
displaySafeEscapedArgFields?: readonly string[];
|
|
542
549
|
|
|
543
550
|
/** The main execution callback for this tool. */
|
|
544
551
|
execute: AgentToolExecFn<TParameters, TDetails, TTheme>;
|