@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 CHANGED
@@ -2,7 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.12.0] - 2026-07-28
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
- export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): number;
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.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Compaction and summarization utilities.
3
3
  */
4
+ export * from "./adaptive";
4
5
  export * from "./branch-summarization";
5
6
  export * from "./compaction";
6
7
  export * from "./entries";
@@ -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.2",
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.2",
39
- "@sayknow-cli/natives": "0.5.2",
40
- "@sayknow-cli/utils": "0.5.2",
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
- return contextWindow - effectiveReserveTokens(contextWindow, settings, maxOutputTokens);
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
- return Math.floor(contextWindow * (clampedThresholdPercent / 100));
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
  // ============================================================================
@@ -2,6 +2,7 @@
2
2
  * Compaction and summarization utilities.
3
3
  */
4
4
 
5
+ export * from "./adaptive";
5
6
  export * from "./branch-summarization";
6
7
  export * from "./compaction";
7
8
  export * from "./entries";
@@ -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
- const args = block.arguments as Record<string, unknown>;
151
- const argsStr = Object.entries(args)
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>;