diff --git a/src/Cli/Commands/Repl/ReplCommand.cs b/src/Cli/Commands/Repl/ReplCommand.cs index bbd4cb6..bbba038 100644 --- a/src/Cli/Commands/Repl/ReplCommand.cs +++ b/src/Cli/Commands/Repl/ReplCommand.cs @@ -155,15 +155,16 @@ protected override async Task ExecuteAsync( using var factory = new ChatClientFactory(); var toolsByCategory = new Dictionary>(StringComparer.OrdinalIgnoreCase); - using ShellPlugin? shellPlugin = settings.NoTools ? null : new ShellPlugin(shellPolicy: TryLoadDefaultShellPolicy()); - SubAgentPlugin? subAgent = null; - SkillsPlugin? skillsPlugin = null; - string? skillsCatalog = null; + using ShellPlugin? shellPlugin = settings.NoTools ? null : new ShellPlugin(shellPolicy: TryLoadDefaultShellPolicy()); + SubAgentPlugin? subAgent = null; + SkillsPlugin? skillsPlugin = null; + string? skillsCatalog = null; List? explorerTools = null; - TodoPlugin? todoPlugin = null; + TodoPlugin? todoPlugin = null; + FileSystemPlugin? fsPluginForCategory = null; if (!settings.NoTools) { - var fsPluginForCategory = new FileSystemPlugin(); + fsPluginForCategory = new FileSystemPlugin(); toolsByCategory["FileSystem"] = PluginRegistry.GetFunctionsFromObject(fsPluginForCategory) .Concat(PluginRegistry.GetFunctionsFromObject(new FileSystemManagementOps(fsPluginForCategory))) .ToList(); @@ -347,6 +348,23 @@ protected override async Task ExecuteAsync( Todo = todoPlugin, KeyStored = keyStored, }; + + if (!settings.NoTools) + { + foreach (var tool in new object?[] { fsPluginForCategory, shellPlugin, todoPlugin }) + { + if (tool is ITurnResettable resettable) + ctx.TurnResettables.Add(resettable); + } + } + + if (toolsByCategory.TryGetValue("FileSystem", out _)) + { + var fsResettable = toolsByCategory["FileSystem"] + .Select(f => f.UnderlyingMethod?.DeclaringType) + .FirstOrDefault(); + } + if (skillsPlugin is not null) ctx.LineReader.SetSkillSlugs([.. skillsPlugin.Slugs]); diff --git a/src/Cli/Commands/Repl/ReplSessionContext.cs b/src/Cli/Commands/Repl/ReplSessionContext.cs index a83c466..682ed18 100644 --- a/src/Cli/Commands/Repl/ReplSessionContext.cs +++ b/src/Cli/Commands/Repl/ReplSessionContext.cs @@ -149,6 +149,9 @@ public IChatClient StepClient // History-aware line reader (shared across turns so history persists) public readonly ReplLineReader LineReader = new(); + + // Turn-scoped plugin state that must be cleared before each new REPL turn. + public readonly List TurnResettables = []; public ReplSessionContext( string cwd, string sessionId, DateTime startedAt, string modelId, ModelConfig modelConfig, @@ -193,6 +196,12 @@ public List GetActiveTools() => [.. ToolsByCategory .Where(kv => !DisabledCategories.Contains(kv.Key)) .SelectMany(kv => kv.Value)]; + public void BeginTurn() + { + foreach (var resettable in TurnResettables) + resettable.BeginTurn(); + } + public ChatOptions? BuildChatOptions() { var active = GetActiveTools(); diff --git a/src/Cli/Commands/Repl/ReplTurn.cs b/src/Cli/Commands/Repl/ReplTurn.cs index b022cea..07e7d39 100644 --- a/src/Cli/Commands/Repl/ReplTurn.cs +++ b/src/Cli/Commands/Repl/ReplTurn.cs @@ -380,6 +380,7 @@ internal static async Task ExecuteAsync( int stepTotal = 0, bool isCorrectionTurn = false) { + ctx.BeginTurn(); ctx.Emitter.SetTurn(ctx.TurnIndex); await ctx.Emitter.EmitAsync(EventTypes.UserInput, turn: ctx.TurnIndex, payload: new { content = input }); ctx.History.Add(new ChatMessage(ChatRole.User, input)); @@ -402,6 +403,24 @@ internal static async Task ExecuteAsync( var turnInputTokens = stream.TurnInputTokens; var turnOutputTokens = stream.TurnOutputTokens; + responseText = SanitizeAssistantResponse(responseText, out var warningMessage); + if (!capturePlan && responseText.Length == 0) + { + if (!isCorrectionTurn) + { + const string correctionMsg = + "Your last reply was empty or contained internal tool-call text. " + + "Respond to the user with a concise, user-facing answer. " + + "If you need tools, call them first and then provide the answer in the same turn."; + return await ExecuteAsync( + ctx, correctionMsg, + isStepRequest: false, capturePlan: false, activeStep: null, + cancellationToken, isCorrectionTurn: true); + } + + warningMessage ??= "Model returned an empty response twice. Provide a real user-facing answer next turn."; + } + if (!capturePlan && responseText.Length > 0 && !ctx.JsonMode) { if (!Console.IsOutputRedirected) @@ -415,16 +434,16 @@ internal static async Task ExecuteAsync( ctx.History.Add(new ChatMessage(ChatRole.Assistant, responseText)); else if (!capturePlan) { - // The model returned zero content — surface a clear warning so the user - // knows to retry rather than wondering why the prompt went quiet. + var warningText = warningMessage ?? "Model returned an empty response. Try sending your message again."; + if (ctx.JsonMode) - ReplJsonBridge.Emit(new { type = "warning", text = "Model returned an empty response. Try sending your message again." }); + ReplJsonBridge.Emit(new { type = "warning", text = warningText }); else - AnsiConsole.MarkupLine("[dim] ↯ empty response — the model returned no content. Try again.[/]"); + AnsiConsole.MarkupLine($"[dim] ↯ {Markup.Escape(warningText)}[/]"); await ctx.Emitter.EmitAsync(EventTypes.ReplWarning, turn: ctx.TurnIndex, payload: new { - message = "empty_response", + message = warningMessage is null ? "empty_response" : "invalid_response_content", }); } @@ -516,7 +535,8 @@ await ctx.Emitter.EmitAsync(EventTypes.HistoryTrimmed, turn: ctx.TurnIndex, AnsiConsole.MarkupLine( $"[dim] tokens (est.): {postEst:N0} / {ctx.ContextTokenBudget:N0} rounds: {toolRounds} tool calls: {toolCallsThisTurn.Count}[/]"); - await ctx.Emitter.EmitAsync(EventTypes.AssistantResponse, turn: ctx.TurnIndex, payload: new { content = responseText }); + if (responseText.Length > 0) + await ctx.Emitter.EmitAsync(EventTypes.AssistantResponse, turn: ctx.TurnIndex, payload: new { content = responseText }); await ctx.Emitter.EmitAsync(EventTypes.TurnEnd, turn: ctx.TurnIndex, payload: new { elapsed_ms = (int)(DateTime.UtcNow - turnStart).TotalMilliseconds, @@ -555,6 +575,26 @@ await ctx.Emitter.EmitAsync(EventTypes.HistoryTrimmed, turn: ctx.TurnIndex, return stepPassed; } + private static string SanitizeAssistantResponse(string responseText, out string? warningMessage) + { + var trimmed = responseText.Trim(); + if (trimmed.Length == 0) + { + warningMessage = null; + return string.Empty; + } + + if (trimmed.StartsWith("to=functions.", StringComparison.OrdinalIgnoreCase) || + trimmed.Contains("Wait must be valid JSON", StringComparison.OrdinalIgnoreCase)) + { + warningMessage = "Model returned internal tool-call text instead of a user-facing answer. Try again."; + return string.Empty; + } + + warningMessage = null; + return responseText; + } + // Free-form turns: if the response claims a mutation but no write tool was called, // auto-inject a correction so the agent is required to actually call the tool. // On the correction turn itself fall back to a warning to avoid infinite recursion. diff --git a/src/Infrastructure/Plugins/FileSystemPlugin.cs b/src/Infrastructure/Plugins/FileSystemPlugin.cs index be00724..663d3ca 100644 --- a/src/Infrastructure/Plugins/FileSystemPlugin.cs +++ b/src/Infrastructure/Plugins/FileSystemPlugin.cs @@ -223,9 +223,24 @@ public async Task ReadFileAsync( $"Cold-reading would flood your context. " + $"Use grep_file to locate the relevant section, then read_file with startLine/maxLines.]"; if (_readBudgetUsed + preview.Length > _readBudgetPerTurn) - return PluginResult.Error( - $"Read budget exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars). " + - $"Proceed with context already available — use patch_file or shell_run. Budget resets next turn."); + { + var remaining = _readBudgetPerTurn - _readBudgetUsed; + if (_readBudgetUsed == 0) + { + var allowed = Math.Max(1, Math.Min(preview.Length, _readBudgetPerTurn)); + preview = preview[..allowed] + + $"\n\n[Truncated to fit per-turn read budget of {_readBudgetPerTurn:N0} chars. " + + $"Use grep_file or read_file with startLine/maxLines for narrower follow-up reads.]"; + } + else + { + var allowed = Math.Max(1, Math.Min(preview.Length, Math.Max(remaining, 1))); + preview = $"[Read budget nearly exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars used this turn). " + + $"Returning a compact preview instead of failing so you can keep working. " + + $"Use grep_file/get_file_summary or narrow read_file ranges for any follow-up reads in this turn.]\n\n" + + preview[..allowed]; + } + } _readBudgetUsed += preview.Length; _sessionCache?.RecordRead(resolved, fileInfo); return preview; @@ -266,10 +281,22 @@ public async Task ReadFileAsync( // proceed with what it already has in context rather than reading more files. if (_readBudgetUsed + built.Length > _readBudgetPerTurn) { - content = null; - return PluginResult.Error( - $"Read budget exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars). " + - $"Proceed with context already available — use patch_file or shell_run. Budget resets next turn."); + if (_readBudgetUsed == 0) + { + var allowed = Math.Max(1, Math.Min(built.Length, _readBudgetPerTurn)); + built = built[..allowed] + + $"\n\n[Truncated to fit per-turn read budget of {_readBudgetPerTurn:N0} chars. " + + $"Use grep_file/get_file_summary or narrow read_file ranges for follow-up reads.]"; + } + else + { + var remaining = _readBudgetPerTurn - _readBudgetUsed; + var allowed = Math.Max(1, Math.Min(built.Length, Math.Max(remaining, 1))); + built = $"[Read budget nearly exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars used this turn). " + + $"Returning a compact slice instead of failing so you can keep working. " + + $"Use grep_file/get_file_summary or narrower read_file ranges for any follow-up reads in this turn.]\n\n" + + built[..allowed]; + } } _readBudgetUsed += built.Length; diff --git a/src/Infrastructure/Plugins/TodoPlugin.cs b/src/Infrastructure/Plugins/TodoPlugin.cs index a0dfa5d..ebba5d5 100644 --- a/src/Infrastructure/Plugins/TodoPlugin.cs +++ b/src/Infrastructure/Plugins/TodoPlugin.cs @@ -42,14 +42,16 @@ public string Write( "status is one of pending, in_progress, completed. Replaces the entire list.")] string itemsJson) { + var candidateJson = ExtractJsonArray(itemsJson); + List? parsed; try { - parsed = JsonSerializer.Deserialize>(itemsJson, JsonOpts); + parsed = JsonSerializer.Deserialize>(candidateJson, JsonOpts); } catch (JsonException ex) { - return $"[ERROR] Could not parse itemsJson as a JSON array: {ex.Message}"; + return $"[ERROR] Could not parse itemsJson as a JSON array: {ex.Message}. Pass only a JSON array like [{{\"content\":\"Example\",\"status\":\"pending\"}}]."; } if (parsed is null) return "[ERROR] itemsJson must be a JSON array of todo items."; @@ -93,6 +95,56 @@ internal static string Render(IReadOnlyList items) } return sb.ToString().TrimEnd(); } + + private static string ExtractJsonArray(string itemsJson) + { + var trimmed = itemsJson.Trim(); + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + return trimmed; + + var start = trimmed.IndexOf('['); + if (start < 0) + return trimmed; + + var depth = 0; + var inString = false; + var escaping = false; + for (var i = start; i < trimmed.Length; i++) + { + var ch = trimmed[i]; + if (escaping) + { + escaping = false; + continue; + } + + if (ch == '\\' && inString) + { + escaping = true; + continue; + } + + if (ch == '"') + { + inString = !inString; + continue; + } + + if (inString) + continue; + + if (ch == '[') + depth++; + else if (ch == ']') + { + depth--; + if (depth == 0) + return trimmed[start..(i + 1)]; + } + } + + return trimmed; + } } public sealed record TodoItem diff --git a/src/Orchestration/Context/ToolResultWindowTrimmer.cs b/src/Orchestration/Context/ToolResultWindowTrimmer.cs index 9ae6700..b1ab052 100644 --- a/src/Orchestration/Context/ToolResultWindowTrimmer.cs +++ b/src/Orchestration/Context/ToolResultWindowTrimmer.cs @@ -25,10 +25,11 @@ namespace fuseraft.Orchestration.Context; public static class ToolResultWindowTrimmer { // Characters per token estimate — consistent with the rest of the codebase. - private const int CharsPerToken = 4; - // Number of original-content chars to include in a tombstone as a content preview. - // Bounded so tombstones stay cheap even for large files (~75 tokens). - private const int ExcerptChars = 300; + private const int CharsPerToken = 4; + // Keep previews small so many evictions do not create a second token spike. + private const int PreviewChars = 160; + private const int PreviewToolLimit = 3; + private const int MaxManifestEvictedLabels = 5; internal const string TombstonePrefix = "[tool result — evicted"; @@ -63,7 +64,7 @@ public static (IList Messages, string? Manifest) ApplyWithManifest( var (trimmed, callLabels, evicted) = ApplyCore(context, budget); if (!evicted) return (trimmed, null); - var active = new List(); + var activeCount = 0; var superseded = new List(); foreach (var msg in trimmed) @@ -71,35 +72,38 @@ public static (IList Messages, string? Manifest) ApplyWithManifest( foreach (var fr in msg.Contents.OfType()) { var callId = fr.CallId ?? "unknown"; - var label = callLabels.GetValueOrDefault(callId, callId); + var label = callLabels.GetValueOrDefault(callId, callId); var result = fr.Result?.ToString() ?? ""; if (result.StartsWith(TombstonePrefix, StringComparison.Ordinal)) - superseded.Add(label); + { + if (superseded.Count < MaxManifestEvictedLabels) + superseded.Add(label); + } else - active.Add(label); + { + activeCount++; + } } } - if (active.Count == 0 && superseded.Count == 0) return (trimmed, null); + if (activeCount == 0 && superseded.Count == 0) return (trimmed, null); var sb = new StringBuilder(); sb.AppendLine("[Context Manifest]"); - - if (active.Count > 0) - { - sb.AppendLine(); - sb.AppendLine($"Active tool results ({active.Count}):"); - foreach (var a in active) sb.AppendLine($"- {a}"); - } + sb.AppendLine(); + sb.AppendLine($"Tool results retained: {activeCount}"); + sb.AppendLine($"Older tool results evicted: {trimmed.SelectMany(m => m.Contents.OfType()).Count(fr => (fr.Result?.ToString() ?? string.Empty).StartsWith(TombstonePrefix, StringComparison.Ordinal))}"); if (superseded.Count > 0) { sb.AppendLine(); - sb.AppendLine($"Superseded ({superseded.Count}) — evicted from context. Re-read with targeted ranges if needed:"); + sb.AppendLine("Most recent evicted:"); foreach (var s in superseded) sb.AppendLine($"- {s}"); } + sb.AppendLine(); + sb.Append("Re-read targeted ranges if needed."); return (trimmed, sb.ToString().TrimEnd()); } @@ -138,17 +142,24 @@ private static (IList Trimmed, Dictionary CallLabel // Fast path — nothing to trim. if (totalEstTokens <= budget.MaxToolResultTokens) return (context, callLabels, false); - // Determine how many of the oldest results to evict. + // Evict only as many oldest results as needed to get back under budget. // Always keep at least the last InTurnToolWindow results verbatim. int retainCount = Math.Max(0, budget.InTurnToolWindow); - int evictUpTo = Math.Max(0, resultMessages.Count - retainCount); - if (evictUpTo == 0) return (context, callLabels, false); + int protectedStart = Math.Max(0, resultMessages.Count - retainCount); - var evictIndices = new HashSet( - resultMessages.Take(evictUpTo).Select(r => r.MsgIdx)); + var evictIndices = new HashSet(); + int runningTokens = totalEstTokens; + for (int i = 0; i < protectedStart && runningTokens > budget.MaxToolResultTokens; i++) + { + evictIndices.Add(resultMessages[i].MsgIdx); + runningTokens -= resultMessages[i].EstTokens; + } + + if (evictIndices.Count == 0) return (context, callLabels, false); // Pass 2: build trimmed list with enriched tombstones. var trimmed = new List(context.Count); + int previewedResults = 0; foreach (var msg in context) { if (evictIndices.Contains(trimmed.Count)) @@ -160,13 +171,11 @@ private static (IList Trimmed, Dictionary CallLabel { if (item is FunctionResultContent fr) { - var callId = fr.CallId ?? "unknown"; - var label = callLabels.GetValueOrDefault(callId, callId); + var callId = fr.CallId ?? "unknown"; + var label = callLabels.GetValueOrDefault(callId, callId); var content = fr.Result?.ToString() ?? ""; - var excerpt = content.Length > 0 - ? (content.Length > ExcerptChars - ? content[..ExcerptChars].TrimEnd() + "…" - : content.Trim()) + var excerpt = previewedResults < PreviewToolLimit + ? BuildPreview(content) : string.Empty; var tombstone = string.IsNullOrEmpty(excerpt) @@ -174,6 +183,7 @@ private static (IList Trimmed, Dictionary CallLabel : $"{TombstonePrefix}: {label}. Preview: \"{excerpt}\". Re-read with targeted ranges if needed.]"; tombstoned.Add(new FunctionResultContent(callId, tombstone)); + previewedResults++; } else { @@ -191,6 +201,16 @@ private static (IList Trimmed, Dictionary CallLabel return (trimmed, callLabels, true); } + private static string BuildPreview(string content) + { + if (string.IsNullOrWhiteSpace(content)) return string.Empty; + + var normalized = content.Trim(); + return normalized.Length > PreviewChars + ? normalized[..PreviewChars].TrimEnd() + "…" + : normalized; + } + private static string FormatCallLabel(FunctionCallContent call) { var name = call.Name ?? "tool"; diff --git a/tests/FuseraftCli.Tests/FileSystemPluginTests.cs b/tests/FuseraftCli.Tests/FileSystemPluginTests.cs index d2428af..3d4665a 100644 --- a/tests/FuseraftCli.Tests/FileSystemPluginTests.cs +++ b/tests/FuseraftCli.Tests/FileSystemPluginTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using fuseraft.Infrastructure; using fuseraft.Infrastructure.Plugins; @@ -488,13 +489,25 @@ public async Task ReadFile_StartLineBeyondFileLength_ReturnsError() } [Fact] - public async Task ReadFile_ReadBudgetExhausted_ReturnsError() + public async Task ReadFile_FirstReadOverBudget_ReturnsTruncatedContent() { var plugin = new FileSystemPlugin(sandboxRoot: _dir, readBudgetPerTurn: 10); await File.WriteAllTextAsync(TempPath("big.txt"), new string('x', 200)); var result = await plugin.ReadFileAsync(TempPath("big.txt")); - Assert.StartsWith("[ERROR]", result); - Assert.Contains("budget", result, StringComparison.OrdinalIgnoreCase); + Assert.True(!result.StartsWith("[ERROR]")); + Assert.Contains("Truncated to fit per-turn read budget", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ReadFile_SubsequentReadAfterBudgetExhausted_ReturnsCompactSlice() + { + var plugin = new FileSystemPlugin(sandboxRoot: _dir, readBudgetPerTurn: 10); + await File.WriteAllTextAsync(TempPath("big.txt"), new string('x', 200)); + await File.WriteAllTextAsync(TempPath("small.txt"), "hello"); + _ = await plugin.ReadFileAsync(TempPath("big.txt")); + var result = await plugin.ReadFileAsync(TempPath("small.txt")); + Assert.False(result.StartsWith("[ERROR]")); + Assert.Contains("Read budget nearly exhausted", result, StringComparison.OrdinalIgnoreCase); } // ----------------------------------------------------------------------- diff --git a/tests/FuseraftCli.Tests/ToolResultWindowTrimmerTests.cs b/tests/FuseraftCli.Tests/ToolResultWindowTrimmerTests.cs index 904b471..c167376 100644 --- a/tests/FuseraftCli.Tests/ToolResultWindowTrimmerTests.cs +++ b/tests/FuseraftCli.Tests/ToolResultWindowTrimmerTests.cs @@ -57,13 +57,36 @@ public void Apply_tombstones_oldest_results_when_budget_exceeded() var result = ToolResultWindowTrimmer.Apply(context, budget); - var first = result[1].Contents.OfType().Single(); + var first = result[1].Contents.OfType().Single(); var second = result[3].Contents.OfType().Single(); Assert.StartsWith(ToolResultWindowTrimmer.TombstonePrefix, first.Result?.ToString()); Assert.DoesNotContain(ToolResultWindowTrimmer.TombstonePrefix, second.Result?.ToString() ?? ""); } + [Fact] + public void Apply_trims_only_enough_oldest_results_to_fit_budget() + { + var context = new List + { + ToolCall("c1", "read_file"), + ToolResult("c1", new string('a', 1_000)), + ToolCall("c2", "read_file"), + ToolResult("c2", new string('b', 1_000)), + ToolCall("c3", "read_file"), + ToolResult("c3", new string('c', 1_000)), + }; + + var result = ToolResultWindowTrimmer.Apply(context, Budget(maxTokens: 550, window: 1)); + + Assert.StartsWith(ToolResultWindowTrimmer.TombstonePrefix, + result[1].Contents.OfType().Single().Result?.ToString()); + Assert.DoesNotContain(ToolResultWindowTrimmer.TombstonePrefix, + result[3].Contents.OfType().Single().Result?.ToString() ?? string.Empty); + Assert.DoesNotContain(ToolResultWindowTrimmer.TombstonePrefix, + result[5].Contents.OfType().Single().Result?.ToString() ?? string.Empty); + } + // ── Apply — item 3: enriched tombstone includes tool label ──────────────── [Fact] @@ -123,7 +146,6 @@ public void Apply_tombstone_includes_content_preview() [Fact] public void Apply_tombstone_truncates_preview_at_excerpt_limit() { - // Content is much longer than ExcerptChars — tombstone must end with the ellipsis marker. var longContent = new string('z', 2_000); var context = new List { @@ -138,10 +160,29 @@ public void Apply_tombstone_truncates_preview_at_excerpt_limit() var tombstone = result[1].Contents.OfType().Single().Result?.ToString(); Assert.NotNull(tombstone); Assert.Contains("…", tombstone); - // The full 2 000-char content must NOT appear verbatim in the tombstone. Assert.DoesNotContain(longContent, tombstone); } + [Fact] + public void Apply_omits_preview_after_first_few_evictions() + { + var context = new List + { + ToolCall("c1", "read_file"), ToolResult("c1", new string('a', 1_000)), + ToolCall("c2", "read_file"), ToolResult("c2", new string('b', 1_000)), + ToolCall("c3", "read_file"), ToolResult("c3", new string('c', 1_000)), + ToolCall("c4", "read_file"), ToolResult("c4", new string('d', 1_000)), + ToolCall("c5", "read_file"), ToolResult("c5", new string('e', 200)), + }; + + var result = ToolResultWindowTrimmer.Apply(context, Budget(100, window: 1)); + + Assert.Contains("Preview:", result[1].Contents.OfType().Single().Result?.ToString()); + Assert.Contains("Preview:", result[3].Contents.OfType().Single().Result?.ToString()); + Assert.Contains("Preview:", result[5].Contents.OfType().Single().Result?.ToString()); + Assert.DoesNotContain("Preview:", result[7].Contents.OfType().Single().Result?.ToString() ?? string.Empty); + } + [Fact] public void Apply_tombstone_includes_re_read_hint() { @@ -222,12 +263,12 @@ public void ApplyWithManifest_manifest_lists_superseded_call() var (_, manifest) = ToolResultWindowTrimmer.ApplyWithManifest(context, Budget(300, window: 1)); - Assert.Contains("Superseded", manifest); + Assert.Contains("Older tool results evicted", manifest); Assert.Contains("read_file", manifest); } [Fact] - public void ApplyWithManifest_manifest_lists_active_call() + public void ApplyWithManifest_manifest_reports_retained_count() { var context = new List { @@ -239,10 +280,11 @@ public void ApplyWithManifest_manifest_lists_active_call() var (_, manifest) = ToolResultWindowTrimmer.ApplyWithManifest(context, Budget(300, window: 1)); - Assert.Contains("Active tool results", manifest); - Assert.Contains("shell_run", manifest); + Assert.Contains("Tool results retained: 1", manifest); + Assert.DoesNotContain("Active tool results", manifest); } + // ── Label formatting ────────────────────────────────────────────────────── [Fact] @@ -320,7 +362,6 @@ public void ApplyWithManifest_falls_back_to_call_id_when_no_matching_call_in_con [Fact] public void ApplyWithManifest_manifest_with_all_results_evicted_shows_only_superseded() { - // window = 0 retains nothing — every result is evicted once budget is exceeded. var context = new List { ToolCall("c1", "read_file"), @@ -332,10 +373,33 @@ public void ApplyWithManifest_manifest_with_all_results_evicted_shows_only_super var (_, manifest) = ToolResultWindowTrimmer.ApplyWithManifest(context, Budget(100, window: 0)); Assert.NotNull(manifest); - Assert.Contains("Superseded", manifest); + Assert.Contains("Older tool results evicted: 2", manifest); + Assert.Contains("Tool results retained: 0", manifest); Assert.DoesNotContain("Active tool results", manifest); } + [Fact] + public void ApplyWithManifest_caps_evicted_labels_in_manifest() + { + var context = new List + { + ToolCall("c1", "read_file", new() { ["path"] = "a" }), ToolResult("c1", new string('a', 1_000)), + ToolCall("c2", "read_file", new() { ["path"] = "b" }), ToolResult("c2", new string('b', 1_000)), + ToolCall("c3", "read_file", new() { ["path"] = "c" }), ToolResult("c3", new string('c', 1_000)), + ToolCall("c4", "read_file", new() { ["path"] = "d" }), ToolResult("c4", new string('d', 1_000)), + ToolCall("c5", "read_file", new() { ["path"] = "e" }), ToolResult("c5", new string('e', 1_000)), + ToolCall("c6", "read_file", new() { ["path"] = "f" }), ToolResult("c6", new string('f', 1_000)), + ToolCall("c7", "read_file", new() { ["path"] = "g" }), ToolResult("c7", new string('g', 200)), + }; + + var (_, manifest) = ToolResultWindowTrimmer.ApplyWithManifest(context, Budget(100, window: 1)); + + Assert.NotNull(manifest); + Assert.Equal(5, manifest.Split(Environment.NewLine).Count(line => line.StartsWith("- "))); + Assert.DoesNotContain("read_file(f)", manifest); + Assert.DoesNotContain("read_file(g)", manifest); + } + // ── Apply — returns same reference when budget disabled ─────────────────── [Fact]