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; } diff --git a/src/Infrastructure/Agents/AgentContextCompactionFilters.cs b/src/Infrastructure/Agents/AgentContextCompactionFilters.cs index a7baf3b2..52099a19 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 @@ -536,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(); @@ -578,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) {