Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 59 additions & 14 deletions src/Cli/Commands/Repl/ReplTurn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -600,31 +600,76 @@ internal static async Task<bool> 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.[/]");
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/Cli/Commands/SettingsSetCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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()),
Expand Down
1 change: 1 addition & 0 deletions src/Cli/Commands/SettingsShowCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ protected override async Task<int> 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();
Expand Down
7 changes: 7 additions & 0 deletions src/Core/Models/Config/ReplDefaultsConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,11 @@ public sealed class ReplDefaultsConfig
/// <summary>Optional plugins enabled by default, e.g. <c>["Scratchpad", "Http"]</c>. Merged with <c>--plugins</c>.</summary>
[JsonPropertyName("plugins")]
public List<string> Plugins { get; set; } = [];

/// <summary>
/// Auto-compact history when context crosses 75% of budget, instead of only warning.
/// Default on; set false to restore the old warn-only behavior.
/// </summary>
[JsonPropertyName("autoCompact")]
public bool AutoCompact { get; set; } = true;
}
26 changes: 22 additions & 4 deletions src/Infrastructure/Agents/AgentContextCompactionFilters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,23 @@ internal static IEnumerable<ChatMessage> 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.]";

/// <summary>
/// For <c>shell_run</c> calls with identical <c>command</c> + <c>workingDirectory</c>
/// arguments, compresses the tool result of earlier calls to a single-line outcome
Expand Down Expand Up @@ -536,8 +545,12 @@ internal static IEnumerable<ChatMessage> 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<ChatMessage>(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();
Expand Down Expand Up @@ -578,7 +591,12 @@ internal static IEnumerable<ChatMessage> 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)
{
Expand Down