From f7a96067d8f2ea99076d2ec387466301d7cb9d1f Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Tue, 8 Sep 2026 11:57:36 -0400 Subject: [PATCH 1/3] fix(repl): auto-compact context at 75% instead of only warning The 75%-full check in ReplTurn.cs only ever printed a suggestion to run /compact manually. In practice this let sessions ride at 75%+ context for many turns with no automatic shrinkage, contributing to oversized turns that burn excess tool-call rounds (observed hitting the 50-round backstop in session f6544c76, turn 11). The check now prefers the provider-reported actual input-token count for the turn (ctx.LastActualContextTokens) over the char-based estimate when available, and calls the existing ReplCommands.CompactHistoryAsync path (already proven via the adaptive-trim-forced auto-compact) instead of only warning. Falls back to the old warn-only text if compaction fails. Adds a repl.autoCompact setting (default on) to opt back into the old warn-only behavior via /settings set repl.autoCompact false. --- src/Cli/Commands/Repl/ReplTurn.cs | 73 ++++++++++++++++---- src/Cli/Commands/SettingsSetCommand.cs | 2 + src/Cli/Commands/SettingsShowCommand.cs | 1 + src/Core/Models/Config/ReplDefaultsConfig.cs | 7 ++ 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/Cli/Commands/Repl/ReplTurn.cs b/src/Cli/Commands/Repl/ReplTurn.cs index 3a7b24b0..5b910597 100644 --- a/src/Cli/Commands/Repl/ReplTurn.cs +++ b/src/Cli/Commands/Repl/ReplTurn.cs @@ -600,31 +600,76 @@ internal static async Task ExecuteAsync( AnsiConsole.MarkupLine($"[dim yellow] ⚠ {Markup.Escape(failMsg)}[/]"); } - // One-time 75 % context warning. Fires on free-form turns only (not - // plan steps or plan-capture) so it never interrupts /execute flow. - // Resets after /compact or /clear so it can fire once per "fill cycle". + // One-time 75 % context check. Fires on free-form turns only (not plan steps or + // plan-capture) so it never interrupts /execute flow. Resets after a successful + // compaction (manual or auto) or /clear so it can fire once per "fill cycle". + // Prefers the provider-reported actual input-token count for this turn's first round + // (LastActualContextTokens) over the char-based heuristic (postEst) when available, + // since it reflects real billed size rather than an estimate — same preference /context + // already uses (see ReplCommands.Context.cs). if (!ctx.ContextWarningShown && !isStepRequest && !capturePlan && responseText.Length > 0) { - var pct = (double)postEst / ctx.ContextTokenBudget; + var isActual = ctx.LastActualContextTokens.HasValue; + var effective = ctx.LastActualContextTokens ?? postEst; + var pct = (double)effective / ctx.ContextTokenBudget; if (pct >= 0.75) { ctx.ContextWarningShown = true; + var autoCompact = ctx.UserCfg?.Repl?.AutoCompact ?? true; await ctx.Emitter.EmitAsync(EventTypes.ContextWarning, turn: ctx.TurnIndex, payload: new { - estimated_tokens = postEst, + estimated_tokens = effective, + is_actual = isActual, budget = ctx.ContextTokenBudget, pct = Math.Round(pct, 3), + auto_compact = autoCompact, }); - if (ctx.JsonMode) - ReplJsonBridge.Emit(new + + var autoCompacted = false; + if (autoCompact) + { + if (!ctx.JsonMode) AnsiConsole.Markup($"[dim] ⚡ context is {pct:P0} full — auto-compacting…[/]"); + var (compacted, compactError, beforeTok, afterTok) = await ReplCommands.CompactHistoryAsync( + ctx, focus: null, cancellationToken, source: "auto_compact_threshold"); + if (!ctx.JsonMode) Console.Write($"\r{new string(' ', 60)}\r"); + + if (compacted) { - type = "warning", - text = $"Context is {pct:P0} full. Consider /compact to summarise and free space.", - }); - else - AnsiConsole.MarkupLine( - $"[dim yellow] ⚠ Context {pct:P0} full — consider [/][bold]/compact[/]" + - $"[dim yellow] to summarise and free space.[/]"); + autoCompacted = true; + ctx.TurnIndex = 0; + ctx.LastExtractedTurnIndex = -1; + if (ctx.JsonMode) + ReplJsonBridge.Emit(new + { + type = "warning", + text = $"Context was {pct:P0} full — auto-compacted ({beforeTok:N0} → {afterTok:N0} tok).", + }); + else + AnsiConsole.MarkupLine( + $"[dim yellow] ⚡ Context was {pct:P0} full — auto-compacted[/] " + + $"[dim]({beforeTok:N0} → {afterTok:N0} tok)[/]"); + } + else if (!ctx.JsonMode) + { + AnsiConsole.MarkupLine( + $"[red] Auto-compaction failed:[/] {Markup.Escape(compactError ?? "unknown error")} " + + "[dim](falling back to warning)[/]"); + } + } + + if (!autoCompacted) + { + if (ctx.JsonMode) + ReplJsonBridge.Emit(new + { + type = "warning", + text = $"Context is {pct:P0} full. Consider /compact to summarise and free space.", + }); + else + AnsiConsole.MarkupLine( + $"[dim yellow] ⚠ Context {pct:P0} full — consider [/][bold]/compact[/]" + + $"[dim yellow] to summarise and free space.[/]"); + } } } diff --git a/src/Cli/Commands/SettingsSetCommand.cs b/src/Cli/Commands/SettingsSetCommand.cs index 376add76..d3df7c12 100644 --- a/src/Cli/Commands/SettingsSetCommand.cs +++ b/src/Cli/Commands/SettingsSetCommand.cs @@ -41,6 +41,7 @@ private static readonly (string Key, string Description)[] ValidKeys = ("repl.noBanner", "true/false"), ("repl.verbose", "true/false"), ("repl.safeMode", "true/false — engage /safe-mode at startup"), + ("repl.autoCompact", "true/false — auto-compact at 75% context instead of only warning"), ("repl.plugins", "Comma-separated plugin list, e.g. Scratchpad,Http"), ("telemetry.otlpEndpoint", "OTLP endpoint URL, or \"\" to disable"), ("telemetry.serviceName", "Requires telemetry.otlpEndpoint to already be set"), @@ -83,6 +84,7 @@ protected override int Execute(CommandContext context, SettingsSetSettings setti "repl.nobanner" => AssignBool(v => config.Repl.NoBanner = v, value), "repl.verbose" => AssignBool(v => config.Repl.Verbose = v, value), "repl.safemode" => AssignBool(v => config.Repl.SafeModeDefault = v, value), + "repl.autocompact" => AssignBool(v => config.Repl.AutoCompact = v, value), "repl.plugins" => Assign(() => config.Repl.Plugins = value .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList()), diff --git a/src/Cli/Commands/SettingsShowCommand.cs b/src/Cli/Commands/SettingsShowCommand.cs index b5940e82..00676cfd 100644 --- a/src/Cli/Commands/SettingsShowCommand.cs +++ b/src/Cli/Commands/SettingsShowCommand.cs @@ -56,6 +56,7 @@ protected override async Task ExecuteAsync(CommandContext context, Cancella repl.AddRow("No banner", config.Repl.NoBanner ? "[green]on[/]" : "[dim]off[/]"); repl.AddRow("Verbose", config.Repl.Verbose ? "[green]on[/]" : "[dim]off[/]"); repl.AddRow("Safe mode", config.Repl.SafeModeDefault ? "[green]on[/]" : "[dim]off[/]"); + repl.AddRow("Auto-compact", config.Repl.AutoCompact ? "[green]on[/]" : "[dim]off[/]"); repl.AddRow("Plugins", config.Repl.Plugins.Count > 0 ? Markup.Escape(string.Join(", ", config.Repl.Plugins)) : "[dim](none)[/]"); AnsiConsole.Write(repl); AnsiConsole.WriteLine(); diff --git a/src/Core/Models/Config/ReplDefaultsConfig.cs b/src/Core/Models/Config/ReplDefaultsConfig.cs index 4f4eab62..96e8eff4 100644 --- a/src/Core/Models/Config/ReplDefaultsConfig.cs +++ b/src/Core/Models/Config/ReplDefaultsConfig.cs @@ -32,4 +32,11 @@ public sealed class ReplDefaultsConfig /// Optional plugins enabled by default, e.g. ["Scratchpad", "Http"]. Merged with --plugins. [JsonPropertyName("plugins")] public List Plugins { get; set; } = []; + + /// + /// Auto-compact history when context crosses 75% of budget, instead of only warning. + /// Default on; set false to restore the old warn-only behavior. + /// + [JsonPropertyName("autoCompact")] + public bool AutoCompact { get; set; } = true; } From 992ec6bb2e9e3137fb693a1d37fabc4d6c163ff8 Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Tue, 8 Sep 2026 12:33:34 -0400 Subject: [PATCH 2/3] fix(agents): make in-turn elision placeholder unmistakably non-literal TruncateArgValue replaces oversized FunctionCallContent argument values (e.g. a write_file content arg) in already-completed, same-turn tool calls with a short size note before resending history to the model on a later round, to bound per-round context growth. The old placeholder text read enough like a real value that the model has been observed re-echoing it verbatim into a brand-new, live tool call - e.g. when asked to move a file it had written earlier in the turn, it reconstructed the content argument from its own (by then elided) history and wrote the placeholder string itself to disk. The filter never mutates a pending/about-to-execute tool call - this was the model treating its own truncated context as ground truth. Reword the note to explicitly warn against reuse and point to the correct recovery (re-read the file or regenerate the value). --- .../Agents/AgentContextCompactionFilters.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Infrastructure/Agents/AgentContextCompactionFilters.cs b/src/Infrastructure/Agents/AgentContextCompactionFilters.cs index a7baf3b2..bc8e2abb 100644 --- a/src/Infrastructure/Agents/AgentContextCompactionFilters.cs +++ b/src/Infrastructure/Agents/AgentContextCompactionFilters.cs @@ -145,14 +145,23 @@ internal static IEnumerable TruncateIntermediateAssistantReasoning( _ => false }; + // NOT the original value — a metadata note describing what was elided. The model has been + // seen re-echoing this exact string as a literal argument value in a later, live tool call + // (e.g. reconstructing a prior write_file's `content` from its own truncated history when + // asked to move/duplicate the file), writing the placeholder itself to disk. The wording + // must make clear this is not reusable content, not just that it was shortened. private static object? TruncateArgValue(object? value) => value switch { - string s => $"[{s.Length:N0} chars — omitted from intermediate context]", + string s => ElisionNote(s.Length), System.Text.Json.JsonElement je when je.ValueKind == System.Text.Json.JsonValueKind.String - => $"[{je.GetString()?.Length ?? 0:N0} chars — omitted from intermediate context]", + => ElisionNote(je.GetString()?.Length ?? 0), _ => value }; + private static string ElisionNote(int originalChars) => + $"[ELIDED — {originalChars:N0} chars, NOT the real value. Do not reuse this placeholder as " + + "content; re-read the file or regenerate the value if you need it again.]"; + /// /// For shell_run calls with identical command + workingDirectory /// arguments, compresses the tool result of earlier calls to a single-line outcome From 64d9c7fda1236b0d2c821876de93b3a864309195 Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Tue, 8 Sep 2026 14:24:22 -0400 Subject: [PATCH 3/3] fix(agents): apply the same non-literal wording to result-side placeholders TrimInTurnContext's two elision placeholders replace/truncate tool *results* (e.g. a large read_file output) in-turn, the same way TruncateArgValue elides oversized call arguments. They had the same latent risk: a vague placeholder that could be mistaken for real data and echoed into a later, live tool call (e.g. treating an elided read_file result as the file's actual contents when writing it elsewhere). Reword both to be explicit about what they are: - The full-replacement placeholder (Phase 1) now says outright it is not the real output and must not be reused as data. - The proportional-truncation suffix (Phase 2) is different in kind: the content before it IS real, only the tail was cut. Reworded to say so explicitly, so the model doesn't treat the retained prefix as the complete result. --- .../Agents/AgentContextCompactionFilters.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Infrastructure/Agents/AgentContextCompactionFilters.cs b/src/Infrastructure/Agents/AgentContextCompactionFilters.cs index bc8e2abb..52099a19 100644 --- a/src/Infrastructure/Agents/AgentContextCompactionFilters.cs +++ b/src/Infrastructure/Agents/AgentContextCompactionFilters.cs @@ -545,8 +545,12 @@ internal static IEnumerable TrimInTurnContext( if (IsTrimmableMessage(list[i])) trimCandidates.Enqueue(i); // Phase 1: replace oldest tool results with a tiny placeholder until under budget. + // Wording mirrors AgentContextCompactionFilters.ElisionNote: not just "shortened" but + // explicitly not the real output, so the model doesn't reuse it as data (e.g. treating + // an elided read_file result as the actual file contents when writing it elsewhere). var result = new List(list); - const string Placeholder = "[result omitted — in-turn context trimmed]"; + const string Placeholder = + "[RESULT ELIDED — not the real output, do not reuse this as data. Re-run the tool if you need this result again.]"; while (total > maxChars && trimCandidates.Count > 0) { int idx = trimCandidates.Dequeue(); @@ -587,7 +591,12 @@ internal static IEnumerable TrimInTurnContext( { int trimBudget = Math.Max(maxChars - protectedChars, 0); int perResultMax = Math.Max(trimBudget / remainingTrimIndices.Count, 200); - const string TruncSuffix = "\n[...truncated — in-turn budget exceeded]"; + // Unlike Placeholder above, the content before this suffix IS real — only + // everything after the cut point is missing. Says so explicitly so the model + // doesn't treat the retained prefix as the complete result. + const string TruncSuffix = + "\n[TRUNCATED HERE — everything after this point was cut for context budget; " + + "this is not the complete output. Re-run the tool if you need the rest.]"; foreach (int idx in remainingTrimIndices) {