@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 +15 -0
- package/dist/types/compaction/adaptive.d.ts +31 -0
- package/dist/types/compaction/compaction.d.ts +5 -1
- package/dist/types/compaction/index.d.ts +1 -0
- package/dist/types/types.d.ts +7 -15
- package/package.json +4 -4
- package/src/agent-loop.ts +84 -48
- package/src/compaction/adaptive.ts +92 -0
- package/src/compaction/compaction.ts +74 -5
- package/src/compaction/index.ts +1 -0
- package/src/compaction/utils.ts +6 -2
- package/src/types.ts +7 -15
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.
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
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 arguments — and 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.
|
|
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.
|
|
36
|
-
"@gajae-code/natives": "0.15.
|
|
37
|
-
"@gajae-code/utils": "0.15.
|
|
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
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
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)
|
|
476
|
-
*
|
|
477
|
-
*
|
|
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
|
|
494
|
-
if (!isDisplayPath(path)
|
|
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,
|
|
521
|
-
* evidence fails closed. Process-keyed scalar/path tags keep
|
|
522
|
-
* carried metadata while still binding every escape
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
3715
|
-
//
|
|
3716
|
-
//
|
|
3717
|
-
//
|
|
3718
|
-
//
|
|
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
|
|
3761
|
-
//
|
|
3762
|
-
//
|
|
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
|
|
4949
|
-
// isDisplaySafeEscapedArguments): user-facing
|
|
4950
|
-
//
|
|
4951
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
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" ? 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.
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
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 arguments — and 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
|
|