@gajae-code/agent-core 0.15.2 → 0.15.4

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,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.15.4] - 2026-08-29
6
+
7
+ ### Added
8
+
9
+ - Added opt-in adaptive compaction thresholding based on context fullness and recent call rate. The default remains disabled, fixed token thresholds keep precedence, and the bounded tracker resets after successful compaction to avoid repeated immediate compactions.
10
+
11
+ ## [0.15.3] - 2026-08-27
12
+
13
+ ### Changed
14
+
15
+ - Escaped non-ASCII tool-call arguments whose every `\uXXXX` escape corroborates a decoded non-ASCII character inside a tool's declared `displaySafeEscapedArgFields` now degrade to a single warning and execute the decoded call, instead of discarding the turn and charging the bounded resample/managed-fallback retry chain. Previously even purely cosmetic display text (e.g. an `ask` question written in Korean or with emoji) burned the full resample budget and then failed the run closed, which made the default Anthropic presets error out on a model-side encoding habit. Raw-evidence corroboration is now per-scalar (offset + process-keyed scalar tag against any decoded non-ASCII character in a declared display field) rather than restricted to U+2014, and literal non-ASCII display text alongside a corroborated escape no longer needs evidence. Load-bearing fields, ASCII-landing escapes, missing/malformed evidence, and non-display-safe tools keep the fail-closed discard-and-reject behavior. (#4983)
16
+ ### Fixed
17
+
18
+ - Compaction no longer crashes on a persisted tool call whose `arguments` payload is null. `serializeConversation` passed that value straight into `Object.entries`, which threw `TypeError: Object.entries requires that input parameter not be null or undefined`. Because this runs inside compaction — itself the recovery path for context overflow — the failure surfaced as `Context overflow recovery failed: Object.entries requires ...`, and the next request went out uncompacted until the provider rejected it with `prompt is too long`. Malformed argument payloads now serialize as an empty argument list instead of aborting the summary.
19
+
5
20
  ## [0.15.2] - 2026-08-25
6
21
 
7
22
  ### Changed
@@ -0,0 +1,31 @@
1
+ export interface AdaptiveCompactionState {
2
+ turnsSinceCompact: number;
3
+ callsInWindow: number;
4
+ windowStart: number;
5
+ lastContextTokens: number;
6
+ lastCompactContextTokens: number | null;
7
+ lastCompactTs: number | null;
8
+ }
9
+ export interface AdaptiveCompactionDecisionState {
10
+ turnsSinceCompact: number;
11
+ callsInWindow: number;
12
+ lastContextTokens?: number;
13
+ }
14
+ export interface AdaptiveCompactionOptions {
15
+ enabled: boolean;
16
+ turnWindow: number;
17
+ baseThresholdPercent: number;
18
+ aggression: number;
19
+ minThresholdPercent?: number;
20
+ }
21
+ export declare class AdaptiveCompactionTracker {
22
+ #private;
23
+ windowMs: number;
24
+ constructor(windowMs?: number, now?: number);
25
+ setWindowMs(windowMs: number, now?: number): void;
26
+ reset(now?: number): void;
27
+ recordCall(contextTokens: number, now?: number): void;
28
+ recordCompact(contextTokens: number, now?: number): void;
29
+ snapshot(): AdaptiveCompactionState;
30
+ decisionState(): AdaptiveCompactionDecisionState;
31
+ }
@@ -7,6 +7,7 @@
7
7
  import { type MessageAttribution, type Model, type ProviderSessionState, type Usage } from "@gajae-code/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,8 @@ export interface CompactionSettings {
32
33
  strategy?: "context-full" | "handoff" | "off";
33
34
  thresholdPercent?: number;
34
35
  thresholdTokens?: number;
36
+ adaptive?: AdaptiveCompactionOptions;
37
+ adaptiveState?: AdaptiveCompactionDecisionState;
35
38
  reserveTokens: number;
36
39
  keepRecentTokens: number;
37
40
  autoContinue?: boolean;
@@ -52,6 +55,7 @@ export interface RemoteCompactionFallbackHealthHooks {
52
55
  recordRemoteCompactionFallback(event: RemoteCompactionFallbackHealthEvent): void;
53
56
  }
54
57
  export declare const DEFAULT_COMPACTION_SETTINGS: CompactionSettings;
58
+ export declare function computeAdaptiveThresholdPercent(basePercent: number, contextTokens: number, contextWindow: number, state: AdaptiveCompactionDecisionState | undefined, options: AdaptiveCompactionOptions | undefined): number;
55
59
  /**
56
60
  * Calculate total context tokens from usage.
57
61
  * Uses the native totalTokens field when available, falls back to computing from components.
@@ -127,7 +131,7 @@ export declare const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLim
127
131
  * normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
128
132
  */
129
133
  export declare function emergencyCompactionReason(sample: EmergencyCompactionSample, limits?: EmergencyCompactionLimits): CompactionTriggerReason | null;
130
- export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): number;
134
+ export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number, contextTokens?: number): number;
131
135
  /**
132
136
  * Image content has no tokenizer representation; charge a fixed estimate
133
137
  * 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";
@@ -166,14 +166,6 @@ export type ManagedAttemptOutcome = {
166
166
  type: "escaped_arguments_discarded";
167
167
  /** The defective assistant turn; already removed from usable history by the loop. */
168
168
  message: AssistantMessage;
169
- /**
170
- * True when this discarded attempt had no transient steering instruction
171
- * attached yet. A managed retry continuation should carry the escaped
172
- * non-ASCII recovery instruction exactly once, so a deterministic
173
- * escaper has a reason to change its spelling; the instruction never
174
- * lands in durable history. Absent/false means steering already ran.
175
- */
176
- steeringPending?: boolean;
177
169
  scope?: AttemptScope;
178
170
  } | {
179
171
  type: "context_overflow_discarded";
@@ -665,13 +657,13 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
665
657
  /**
666
658
  * Argument fields (dotted paths into the arguments object) that render to
667
659
  * the user as pure display text — question wording and option labels, never
668
- * ids, metadata, or persisted records. The agent loop uses this to bound the
669
- * escaped-non-ASCII terminal rejection AFTER the resample budget is spent:
670
- * when every non-ASCII character under these fields is benign typographic
671
- * punctuation (e.g. an em-dash a JSON encoder escaped), the call executes
672
- * instead of failing closed. Every other field of these arguments and
673
- * every field of every other tool keeps the fail-closed rejection for
674
- * unverified `\uXXXX` payloads.
660
+ * ids, metadata, or persisted records. When every `\uXXXX`-escaped scalar in
661
+ * a tool call corroborates a decoded non-ASCII character inside these
662
+ * fields, the agent loop degrades to a single warning and executes the
663
+ * decoded call instead of discarding the turn: a mistyped hex digit there
664
+ * can only change what the user reads, never what executes or persists.
665
+ * Every other field of these argumentsand every field of every other
666
+ * tool — keeps the fail-closed rejection for unverified `\uXXXX` payloads.
675
667
  */
676
668
  displaySafeEscapedArgFields?: readonly string[];
677
669
  /** The main execution callback for this tool. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.15.2",
4
+ "version": "0.15.4",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -32,9 +32,9 @@
32
32
  "fmt": "biome format --write ."
33
33
  },
34
34
  "dependencies": {
35
- "@gajae-code/ai": "0.15.2",
36
- "@gajae-code/natives": "0.15.2",
37
- "@gajae-code/utils": "0.15.2",
35
+ "@gajae-code/ai": "0.15.4",
36
+ "@gajae-code/natives": "0.15.4",
37
+ "@gajae-code/utils": "0.15.4",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -437,17 +437,14 @@ function stripUnicodeEscapeEvidence(message: AssistantMessage): void {
437
437
  }
438
438
 
439
439
  /**
440
- * The complete set of non-ASCII characters an escaped payload may decode to and
441
- * still execute on a display-safe tool after the resample budget: U+2014 EM DASH
442
- * only. This is the exact character JSON encoders spell as `\u2014` that
443
- * motivated the false positive an English question text like "sessions —
444
- * in-process?" with no other non-ASCII. Curated deliberately: a mistyped nibble
445
- * here can only produce another punctuation/letter codepoint, and any such
446
- * landing (or any other escaped character at all) keeps the fail-closed
447
- * rejection. Currency, math, full-width, separator, letter, mark, number, and
448
- * surrogate escapes all stay rejected everywhere.
440
+ * Display-safe handling for tools that opt specific argument fields into it:
441
+ * every non-ASCII character of the decoded arguments must live inside one of
442
+ * the declared display-field paths, and the raw escape evidence must
443
+ * corroborate each escaped scalar against the decoded display text. A
444
+ * mistyped nibble here can only change what the user reads on screen, never
445
+ * what executes or persists, so the call degrades to a single warning instead
446
+ * of the fail-closed rejection that load-bearing fields keep.
449
447
  */
450
- const DISPLAY_SAFE_ESCAPED_CODEPOINTS = new Set([0x2014]);
451
448
 
452
449
  /** Structural type for tools that opt specific argument fields into display-safe handling. */
453
450
  type DisplaySafeEscapedTool = AgentTool<TSchema> & {
@@ -455,14 +452,6 @@ type DisplaySafeEscapedTool = AgentTool<TSchema> & {
455
452
  displaySafeEscapedArgFields?: readonly string[];
456
453
  };
457
454
 
458
- /**
459
- * Whether a non-ASCII codepoint is benign typographic punctuation that a JSON
460
- * encoder may escape in display text. See {@link DISPLAY_SAFE_ESCAPED_CODEPOINTS}.
461
- */
462
- function isDisplaySafeEscapedCodepoint(cp: number): boolean {
463
- return DISPLAY_SAFE_ESCAPED_CODEPOINTS.has(cp);
464
- }
465
-
466
455
  /** Whether the tool declared any display-only argument fields at all. */
467
456
  function isDisplaySafeEscapedTool(tool: AgentTool<TSchema> | undefined): boolean {
468
457
  return ((tool as DisplaySafeEscapedTool | undefined)?.displaySafeEscapedArgFields?.length ?? 0) > 0;
@@ -472,10 +461,9 @@ function isDisplaySafeEscapedTool(tool: AgentTool<TSchema> | undefined): boolean
472
461
  * Walk the decoded arguments and decide whether the escaped payload is
473
462
  * display-safe: every non-ASCII character must live inside one of the tool's
474
463
  * declared display-field paths (dotted, array-index-free: `questions.question`
475
- * matches every question in the `questions` array) AND be benign typographic
476
- * punctuation. Any non-ASCII outside the display fields — ids, metadata,
477
- * persisted records, or an object key — keeps the fail-closed rejection, as
478
- * does any non-benign codepoint inside them.
464
+ * matches every question in the `questions` array). Any non-ASCII outside the
465
+ * display fields — ids, metadata, persisted records, or an object key — keeps
466
+ * the fail-closed rejection.
479
467
  */
480
468
  function isDisplaySafeEscapedArguments(tool: AgentTool<TSchema> | undefined, args: Record<string, unknown>): boolean {
481
469
  const fields = (tool as DisplaySafeEscapedTool | undefined)?.displaySafeEscapedArgFields;
@@ -490,8 +478,8 @@ function isDisplaySafeEscapedArguments(tool: AgentTool<TSchema> | undefined, arg
490
478
  const cp = ch.codePointAt(0);
491
479
  if (cp === undefined || cp < 0x80) continue;
492
480
  // Outside the display fields no non-ASCII is tolerated at all;
493
- // inside them only the curated punctuation set is.
494
- if (!isDisplayPath(path) || !isDisplaySafeEscapedCodepoint(cp)) return false;
481
+ // inside them any decoded character is display text.
482
+ if (!isDisplayPath(path)) return false;
495
483
  }
496
484
  return true;
497
485
  }
@@ -517,9 +505,10 @@ function isDisplaySafeEscapedArguments(tool: AgentTool<TSchema> | undefined, arg
517
505
 
518
506
  /**
519
507
  * Validate the original raw escape positions, not just the decoded values.
520
- * Missing, malformed, overflowed, key-position, non-U+2014, or path-mismatched
521
- * evidence fails closed. Process-keyed scalar/path tags keep argument text out of the
522
- * carried metadata while still binding every escape to a declared display field.
508
+ * Missing, malformed, overflowed, key-position, ASCII-landing, or
509
+ * path-mismatched evidence fails closed. Process-keyed scalar/path tags keep
510
+ * argument text out of the carried metadata while still binding every escape
511
+ * to a decoded non-ASCII character inside a declared display field.
523
512
  */
524
513
  function isDisplaySafeRawEscapeEvidence(
525
514
  tool: AgentTool<TSchema> | undefined,
@@ -538,7 +527,7 @@ function isDisplaySafeRawEscapeEvidence(
538
527
  return false;
539
528
  if (evidence.positions.length === 0 || evidence.positions.length > 32) return false;
540
529
 
541
- const allowedValues = new Map<string, { offsets: Set<number>; matched: Set<number> }>();
530
+ const allowedValues = new Map<string, { offsets: Map<number, string>; matched: Set<number> }>();
542
531
  const valueOrdinals = new Map<string, number>();
543
532
  const prefixes = fields.map(field => field.split("."));
544
533
  const isDisplayPath = (path: readonly string[]): boolean =>
@@ -550,10 +539,10 @@ function isDisplaySafeRawEscapeEvidence(
550
539
  const pathTag = unicodeEscapePathTag(path);
551
540
  const valueOrdinal = valueOrdinals.get(pathTag) ?? 0;
552
541
  valueOrdinals.set(pathTag, valueOrdinal + 1);
553
- const offsets = new Set<number>();
542
+ const offsets = new Map<number, string>();
554
543
  for (let offset = 0; offset < node.length; ) {
555
544
  const codePoint = node.codePointAt(offset);
556
- if (codePoint === 0x2014) offsets.add(offset);
545
+ if (codePoint !== undefined && codePoint >= 0x80) offsets.set(offset, unicodeEscapeScalarTag(codePoint));
557
546
  offset += codePoint !== undefined && codePoint > 0xffff ? 2 : 1;
558
547
  }
559
548
  allowedValues.set(`${pathTag}:${valueOrdinal}`, { offsets, matched: new Set() });
@@ -574,11 +563,10 @@ function isDisplaySafeRawEscapeEvidence(
574
563
  walk(args);
575
564
 
576
565
  let previousOffset = -1;
577
- const allowedScalarTag = unicodeEscapeScalarTag(0x2014);
578
566
  for (const position of evidence.positions) {
579
567
  if (
580
568
  position.location !== "value" ||
581
- position.scalarTag !== allowedScalarTag ||
569
+ !/^[0-9a-f]{64}$/.test(position.scalarTag) ||
582
570
  !Number.isSafeInteger(position.offset) ||
583
571
  position.offset <= previousOffset ||
584
572
  !/^[0-9a-f]{64}$/.test(position.pathTag) ||
@@ -591,10 +579,44 @@ function isDisplaySafeRawEscapeEvidence(
591
579
  }
592
580
  previousOffset = position.offset;
593
581
  const value = allowedValues.get(`${position.pathTag}:${position.valueOrdinal}`);
594
- if (!value?.offsets.has(position.valueOffset) || value.matched.has(position.valueOffset)) return false;
582
+ // Each escape must corroborate an actual decoded non-ASCII character at
583
+ // that exact offset inside a declared display string: an ASCII landing
584
+ // (a possible one-nibble mutation of a non-ASCII escape), a load-bearing
585
+ // field, or a shifted position all fail closed.
586
+ if (
587
+ !value ||
588
+ value.offsets.get(position.valueOffset) !== position.scalarTag ||
589
+ value.matched.has(position.valueOffset)
590
+ )
591
+ return false;
595
592
  value.matched.add(position.valueOffset);
596
593
  }
597
- return [...allowedValues.values()].every(value => value.offsets.size === value.matched.size);
594
+ return true;
595
+ }
596
+ /**
597
+ * Whether every escaped tool call in the turn lands entirely on declared
598
+ * display-safe fields of its registered tool. Only then does the turn skip
599
+ * the resample/discard chain: a single load-bearing escaped call keeps the
600
+ * whole turn fail-closed.
601
+ */
602
+ function allEscapedToolCallsDisplaySafe(
603
+ message: AssistantMessage,
604
+ tools: readonly AgentTool<TSchema>[] | undefined,
605
+ ): boolean {
606
+ let sawEscapedCall = false;
607
+ for (const block of message.content) {
608
+ if (block.type !== "toolCall") continue;
609
+ if (block.escapedNonAsciiArguments !== true && block.escapedUnicodeArgumentEvidence === undefined) continue;
610
+ sawEscapedCall = true;
611
+ const tool = tools?.find(candidate => candidate.name === block.name);
612
+ const args = block.arguments as Record<string, unknown>;
613
+ if (
614
+ !isDisplaySafeEscapedArguments(tool, args) ||
615
+ !isDisplaySafeRawEscapeEvidence(tool, args, block.escapedUnicodeArgumentEvidence)
616
+ )
617
+ return false;
618
+ }
619
+ return sawEscapedCall;
598
620
  }
599
621
  /** Remove only the exact assistant response committed by its streaming attempt. */
600
622
  function removeCommittedAssistantMessage(messages: AgentMessage[], message: AssistantMessage): boolean {
@@ -3711,12 +3733,27 @@ async function runLoopBody(
3711
3733
  // owns a bounded same-model retry; the defect is never treated as
3712
3734
  // provider evidence, so the fallback chain never advances on it.
3713
3735
  //
3714
- // The budget runs unconditionally for escaped payloads: even a
3715
- // display-safe tool (whose terminal exemption lives in
3716
- // `executeToolCalls`) resamples here first, so the model always gets
3717
- // its chances to emit literal UTF-8 and the fallback stays rare
3718
- // rather than becoming the default path.
3736
+ // The single bounded exception is the display-safe degrade: when
3737
+ // every escaped call in the turn corroborates its escaped scalars
3738
+ // against decoded non-ASCII text inside the tool's declared
3739
+ // display-only fields, a mistyped nibble can only change what the
3740
+ // user reads on screen. That turn skips the resample/discard chain
3741
+ // entirely — no managed retry is charged — executes the decoded
3742
+ // call, and warns once so the fire rate stays measurable.
3719
3743
  if (
3744
+ message.stopReason !== "error" &&
3745
+ message.stopReason !== "aborted" &&
3746
+ hasEscapedNonAsciiToolCall(message) &&
3747
+ allEscapedToolCallsDisplaySafe(message, currentContext.tools)
3748
+ ) {
3749
+ // Display-safe degrade: execute the decoded arguments as-is and warn
3750
+ // once (shape-only, never names or payload). The turn is neither
3751
+ // resampled nor reported to the managed fallback policy.
3752
+ logger.warn("agent: executing a tool-call turn whose display-safe arguments were \\uXXXX-escaped", {
3753
+ mode: config.fallbackManaged ? "managed" : "in_loop",
3754
+ ...escapedNonAsciiToolCallShape(message),
3755
+ });
3756
+ } else if (
3720
3757
  message.stopReason !== "error" &&
3721
3758
  message.stopReason !== "aborted" &&
3722
3759
  escapedNonAsciiResampleAttempt < MAX_ESCAPED_NONASCII_RESAMPLES &&
@@ -3757,10 +3794,9 @@ async function runLoopBody(
3757
3794
  // outcome below; the policy owns the same-model bounded retry and
3758
3795
  // only falls back once it declines. The wire defect is not provider
3759
3796
  // evidence, so the outcome deliberately carries no transport facts
3760
- // and the fallback chain never advances on it. The outcome names
3761
- // whether a steering instruction already rode this attempt, so the
3762
- // policy's retry continuation can carry it exactly once instead of
3763
- // blindly re-requesting the same defective spelling.
3797
+ // and the fallback chain never advances on it. The policy's retry
3798
+ // continuation attaches transient steering on every bounded re-issue
3799
+ // instead of blindly re-requesting the same defective spelling.
3764
3800
  if (config.fallbackManaged) {
3765
3801
  transaction?.discard();
3766
3802
  currentContext.messages.splice(contextMessageCount);
@@ -3768,7 +3804,6 @@ async function runLoopBody(
3768
3804
  await config.onManagedAttemptOutcome?.({
3769
3805
  type: "escaped_arguments_discarded",
3770
3806
  message,
3771
- steeringPending: recoveryAttempt?.kind !== "escaped-nonascii",
3772
3807
  scope: transaction?.scope,
3773
3808
  });
3774
3809
  stream.end(newMessages);
@@ -4945,10 +4980,11 @@ async function executeToolCalls(
4945
4980
  // equally valid character — the payload is unverifiable and cannot be
4946
4981
  // repaired after parsing, so it is rejected rather than executed on
4947
4982
  // silently corrupted text. The one bounded exception is a tool that
4948
- // declared its arguments display-only (see
4949
- // isDisplaySafeEscapedArguments): user-facing question text whose
4950
- // only escaped scalars are exact U+2014 positions, corroborated against
4951
- // the decoded display fields. Missing or ASCII-position evidence rejects.
4983
+ // declared display-only argument fields (see
4984
+ // isDisplaySafeEscapedArguments): user-facing display text whose
4985
+ // escaped scalars all corroborate decoded non-ASCII characters
4986
+ // inside those fields. Missing, malformed, or ASCII-position
4987
+ // evidence rejects.
4952
4988
  //
4953
4989
  // Terminal for this call: the resample budget is already spent, so
4954
4990
  // log it (shape-only) to make the fire rate measurable without
@@ -0,0 +1,92 @@
1
+ export interface AdaptiveCompactionState {
2
+ turnsSinceCompact: number;
3
+ callsInWindow: number;
4
+ windowStart: number;
5
+ lastContextTokens: number;
6
+ lastCompactContextTokens: number | null;
7
+ lastCompactTs: number | null;
8
+ }
9
+
10
+ export interface AdaptiveCompactionDecisionState {
11
+ turnsSinceCompact: number;
12
+ callsInWindow: number;
13
+ lastContextTokens?: number;
14
+ }
15
+
16
+ export interface AdaptiveCompactionOptions {
17
+ enabled: boolean;
18
+ turnWindow: number;
19
+ baseThresholdPercent: number;
20
+ aggression: number;
21
+ minThresholdPercent?: number;
22
+ }
23
+
24
+ export class AdaptiveCompactionTracker {
25
+ #state: AdaptiveCompactionState;
26
+ windowMs: number;
27
+
28
+ constructor(windowMs = 60_000, now = Date.now()) {
29
+ this.windowMs = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : 60_000;
30
+ this.#state = {
31
+ turnsSinceCompact: 0,
32
+ callsInWindow: 0,
33
+ windowStart: now,
34
+ lastContextTokens: 0,
35
+ lastCompactContextTokens: null,
36
+ lastCompactTs: null,
37
+ };
38
+ }
39
+
40
+ setWindowMs(windowMs: number, now = Date.now()): void {
41
+ if (!Number.isFinite(windowMs)) return;
42
+ const nextWindowMs = Math.max(1, windowMs);
43
+ if (nextWindowMs === this.windowMs) return;
44
+ this.windowMs = nextWindowMs;
45
+ this.#state.windowStart = now;
46
+ this.#state.callsInWindow = 0;
47
+ }
48
+
49
+ reset(now = Date.now()): void {
50
+ this.#state = {
51
+ turnsSinceCompact: 0,
52
+ callsInWindow: 0,
53
+ windowStart: now,
54
+ lastContextTokens: 0,
55
+ lastCompactContextTokens: null,
56
+ lastCompactTs: null,
57
+ };
58
+ }
59
+
60
+ recordCall(contextTokens: number, now = Date.now()): void {
61
+ const timestamp = Number.isFinite(now) ? now : Date.now();
62
+ this.#state.turnsSinceCompact += 1;
63
+ if (timestamp - this.#state.windowStart >= this.windowMs) {
64
+ this.#state.windowStart = timestamp;
65
+ this.#state.callsInWindow = 0;
66
+ }
67
+ this.#state.callsInWindow += 1;
68
+ this.#state.lastContextTokens = contextTokens;
69
+ }
70
+
71
+ recordCompact(contextTokens: number, now = Date.now()): void {
72
+ const timestamp = Number.isFinite(now) ? now : Date.now();
73
+ this.#state.turnsSinceCompact = 0;
74
+ this.#state.callsInWindow = 0;
75
+ this.#state.windowStart = timestamp;
76
+ this.#state.lastContextTokens = contextTokens;
77
+ this.#state.lastCompactContextTokens = contextTokens;
78
+ this.#state.lastCompactTs = timestamp;
79
+ }
80
+
81
+ snapshot(): AdaptiveCompactionState {
82
+ return { ...this.#state };
83
+ }
84
+
85
+ decisionState(): AdaptiveCompactionDecisionState {
86
+ return {
87
+ turnsSinceCompact: this.#state.turnsSinceCompact,
88
+ callsInWindow: this.#state.callsInWindow,
89
+ lastContextTokens: this.#state.lastContextTokens,
90
+ };
91
+ }
92
+ }
@@ -18,6 +18,7 @@ import {
18
18
  import { logger, prompt } from "@gajae-code/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 {
@@ -33,7 +34,6 @@ import compactionSummaryPrompt from "./prompts/compaction-summary.md" with { typ
33
34
  import compactionTurnPrefixPrompt from "./prompts/compaction-turn-prefix.md" with { type: "text" };
34
35
  import compactionUpdateSummaryPrompt from "./prompts/compaction-update-summary.md" with { type: "text" };
35
36
  import handoffDocumentPrompt from "./prompts/handoff-document.md" with { type: "text" };
36
-
37
37
  import {
38
38
  computeFileLists,
39
39
  createFileOps,
@@ -136,6 +136,8 @@ export interface CompactionSettings {
136
136
  strategy?: "context-full" | "handoff" | "off";
137
137
  thresholdPercent?: number;
138
138
  thresholdTokens?: number;
139
+ adaptive?: AdaptiveCompactionOptions;
140
+ adaptiveState?: AdaptiveCompactionDecisionState;
139
141
  reserveTokens: number;
140
142
  keepRecentTokens: number;
141
143
  autoContinue?: boolean;
@@ -166,6 +168,40 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
166
168
  remoteEnabled: true,
167
169
  };
168
170
 
171
+ export function computeAdaptiveThresholdPercent(
172
+ basePercent: number,
173
+ contextTokens: number,
174
+ contextWindow: number,
175
+ state: AdaptiveCompactionDecisionState | undefined,
176
+ options: AdaptiveCompactionOptions | undefined,
177
+ ): number {
178
+ const clampedBasePercent = Number.isFinite(basePercent) ? Math.min(99, Math.max(1, basePercent)) : 85;
179
+ if (!options?.enabled) return basePercent;
180
+ if (!state || !Number.isFinite(contextWindow) || contextWindow <= 0) return clampedBasePercent;
181
+ if (!Number.isFinite(options.turnWindow) || options.turnWindow <= 0) return clampedBasePercent;
182
+
183
+ const safeContextTokens = Number.isFinite(contextTokens) ? Math.max(0, contextTokens) : 0;
184
+ const fillRatio = safeContextTokens / contextWindow;
185
+ const baseRatio = clampedBasePercent / 100;
186
+ if (fillRatio < baseRatio * 0.7) return clampedBasePercent;
187
+
188
+ const turnsSinceCompact = Number.isFinite(state.turnsSinceCompact) ? Math.max(0, state.turnsSinceCompact) : 0;
189
+ const callsInWindow = Number.isFinite(state.callsInWindow) ? Math.max(0, state.callsInWindow) : 0;
190
+ if (turnsSinceCompact <= 3) return clampedBasePercent;
191
+ const windowTurns = Math.max(1, options.turnWindow * 4);
192
+ const intensity = Math.min(1, callsInWindow / windowTurns);
193
+ const aggression = Number.isFinite(options.aggression) ? Math.min(1, Math.max(0, options.aggression)) : 0;
194
+ const configuredMinThresholdPercent = options.minThresholdPercent;
195
+ const minThresholdPercent = Math.min(
196
+ clampedBasePercent,
197
+ typeof configuredMinThresholdPercent === "number" && Number.isFinite(configuredMinThresholdPercent)
198
+ ? Math.max(1, configuredMinThresholdPercent)
199
+ : clampedBasePercent * 0.5,
200
+ );
201
+ const loweredPercent = clampedBasePercent - (clampedBasePercent - minThresholdPercent) * aggression * intensity;
202
+ return Math.max(1, Math.min(99, Math.round(loweredPercent)));
203
+ }
204
+
169
205
  // ============================================================================
170
206
  // Token calculation
171
207
  // ============================================================================
@@ -244,7 +280,7 @@ export function shouldCompact(
244
280
  maxOutputTokens = 0,
245
281
  ): boolean {
246
282
  if (!settings.enabled || settings.strategy === "off" || contextWindow <= 0) return false;
247
- const thresholdTokens = resolveThresholdTokens(contextWindow, settings, maxOutputTokens);
283
+ const thresholdTokens = resolveThresholdTokens(contextWindow, settings, maxOutputTokens, contextTokens);
248
284
  return contextTokens > thresholdTokens;
249
285
  }
250
286
 
@@ -381,6 +417,7 @@ export function resolveThresholdTokens(
381
417
  contextWindow: number,
382
418
  settings: CompactionSettings,
383
419
  maxOutputTokens = 0,
420
+ contextTokens?: number,
384
421
  ): number {
385
422
  // Fixed token limit takes priority over percentage
386
423
  const thresholdTokens = settings.thresholdTokens;
@@ -392,10 +429,38 @@ export function resolveThresholdTokens(
392
429
  // Percentage-based threshold
393
430
  const thresholdPercent = settings.thresholdPercent;
394
431
  if (typeof thresholdPercent !== "number" || !Number.isFinite(thresholdPercent) || thresholdPercent <= 0) {
395
- return contextWindow - effectiveReserveTokens(contextWindow, settings, maxOutputTokens);
432
+ if (!settings.adaptive?.enabled) {
433
+ return contextWindow - effectiveReserveTokens(contextWindow, settings, maxOutputTokens);
434
+ }
435
+ const adaptiveBasePercent = Number.isFinite(settings.adaptive.baseThresholdPercent)
436
+ ? Math.min(99, Math.max(1, settings.adaptive.baseThresholdPercent))
437
+ : 85;
438
+ const adaptiveThresholdPercent = computeAdaptiveThresholdPercent(
439
+ adaptiveBasePercent,
440
+ adaptiveContextTokens(contextTokens, settings.adaptiveState?.lastContextTokens),
441
+ contextWindow,
442
+ settings.adaptiveState,
443
+ settings.adaptive,
444
+ );
445
+ return Math.floor(contextWindow * (adaptiveThresholdPercent / 100));
396
446
  }
397
447
  const clampedThresholdPercent = Math.min(99, Math.max(1, thresholdPercent));
398
- return Math.floor(contextWindow * (clampedThresholdPercent / 100));
448
+ const adaptiveThresholdPercent = computeAdaptiveThresholdPercent(
449
+ settings.adaptive?.baseThresholdPercent ?? clampedThresholdPercent,
450
+ adaptiveContextTokens(contextTokens, settings.adaptiveState?.lastContextTokens),
451
+ contextWindow,
452
+ settings.adaptiveState,
453
+ settings.adaptive,
454
+ );
455
+ const effectiveThresholdPercent = settings.adaptive?.enabled ? adaptiveThresholdPercent : clampedThresholdPercent;
456
+ return Math.floor(contextWindow * (effectiveThresholdPercent / 100));
457
+ }
458
+
459
+ function adaptiveContextTokens(contextTokens: number | undefined, lastContextTokens: number | undefined): number {
460
+ if (contextTokens !== undefined && Number.isFinite(contextTokens)) return Math.max(0, contextTokens);
461
+ if (typeof lastContextTokens === "number" && Number.isFinite(lastContextTokens))
462
+ return Math.max(0, lastContextTokens);
463
+ return 0;
399
464
  }
400
465
 
401
466
  // ============================================================================
@@ -543,7 +608,11 @@ function collectMessageFragments(message: AgentMessage): { fragments: string[];
543
608
  fragments.push(block.thinking);
544
609
  } else if (block.type === "toolCall") {
545
610
  fragments.push(block.name);
546
- fragments.push(JSON.stringify(block.arguments));
611
+ // `arguments` is typed non-null, but persisted history can carry a
612
+ // null/undefined payload from an aborted or malformed tool call;
613
+ // JSON.stringify returns undefined for those, and the token
614
+ // fingerprint below requires string fragments.
615
+ fragments.push(JSON.stringify(block.arguments) ?? "null");
547
616
  }
548
617
  }
549
618
  break;
@@ -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" ? 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
@@ -186,14 +186,6 @@ export type ManagedAttemptOutcome =
186
186
  type: "escaped_arguments_discarded";
187
187
  /** The defective assistant turn; already removed from usable history by the loop. */
188
188
  message: AssistantMessage;
189
- /**
190
- * True when this discarded attempt had no transient steering instruction
191
- * attached yet. A managed retry continuation should carry the escaped
192
- * non-ASCII recovery instruction exactly once, so a deterministic
193
- * escaper has a reason to change its spelling; the instruction never
194
- * lands in durable history. Absent/false means steering already ran.
195
- */
196
- steeringPending?: boolean;
197
189
  scope?: AttemptScope;
198
190
  }
199
191
  | { type: "context_overflow_discarded"; message: AssistantMessage; scope?: AttemptScope }
@@ -740,13 +732,13 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
740
732
  /**
741
733
  * Argument fields (dotted paths into the arguments object) that render to
742
734
  * the user as pure display text — question wording and option labels, never
743
- * ids, metadata, or persisted records. The agent loop uses this to bound the
744
- * escaped-non-ASCII terminal rejection AFTER the resample budget is spent:
745
- * when every non-ASCII character under these fields is benign typographic
746
- * punctuation (e.g. an em-dash a JSON encoder escaped), the call executes
747
- * instead of failing closed. Every other field of these arguments and
748
- * every field of every other tool keeps the fail-closed rejection for
749
- * unverified `\uXXXX` payloads.
735
+ * ids, metadata, or persisted records. When every `\uXXXX`-escaped scalar in
736
+ * a tool call corroborates a decoded non-ASCII character inside these
737
+ * fields, the agent loop degrades to a single warning and executes the
738
+ * decoded call instead of discarding the turn: a mistyped hex digit there
739
+ * can only change what the user reads, never what executes or persists.
740
+ * Every other field of these argumentsand every field of every other
741
+ * tool — keeps the fail-closed rejection for unverified `\uXXXX` payloads.
750
742
  */
751
743
  displaySafeEscapedArgFields?: readonly string[];
752
744