From e29bd06f04d5cb6c7d35d8421076857adb9eeb6c Mon Sep 17 00:00:00 2001 From: "Stauffer, Scott" Date: Sat, 1 Aug 2026 07:07:04 -0400 Subject: [PATCH 1/6] perf: reduce token overhead in tool result manifests - Limit previews to first 3 evicted results (160 chars each, down from 300) - Cap evicted labels shown in manifest to 5 entries - Replace full active tool list with a count to save tokens - Evict only oldest results needed to fit budget (not all unprotected results) Measured impact: ~75% token reduction in manifest overhead when many tool results are evicted. The count-based format scales better for agents that read dozens of files. --- .../Context/ToolResultWindowTrimmer.cs | 76 ++++++++++------- .../ToolResultWindowTrimmerTests.cs | 82 +++++++++++++++++-- 2 files changed, 121 insertions(+), 37 deletions(-) diff --git a/src/Orchestration/Context/ToolResultWindowTrimmer.cs b/src/Orchestration/Context/ToolResultWindowTrimmer.cs index 9ae67004..b1ab0528 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/ToolResultWindowTrimmerTests.cs b/tests/FuseraftCli.Tests/ToolResultWindowTrimmerTests.cs index 904b471c..c1673763 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] From 496b1a97c213d56ccee0f61c3375fed9d0f3d053 Mon Sep 17 00:00:00 2001 From: "Stauffer, Scott" Date: Sat, 1 Aug 2026 07:27:46 -0400 Subject: [PATCH 2/6] fix: sanitize malformed LLM responses in REPL and todo plugin - Detect and strip internal tool-call syntax that leaks when models hallucinate - Extract JSON arrays from responses wrapped in markdown or prose - Improve error messages to guide agents toward correct JSON format - Prevents user-facing display of internal implementation details --- src/Cli/Commands/Repl/ReplTurn.cs | 32 +++++++++++--- src/Infrastructure/Plugins/TodoPlugin.cs | 56 +++++++++++++++++++++++- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/Cli/Commands/Repl/ReplTurn.cs b/src/Cli/Commands/Repl/ReplTurn.cs index b022ceae..76395fc0 100644 --- a/src/Cli/Commands/Repl/ReplTurn.cs +++ b/src/Cli/Commands/Repl/ReplTurn.cs @@ -402,6 +402,8 @@ internal static async Task ExecuteAsync( var turnInputTokens = stream.TurnInputTokens; var turnOutputTokens = stream.TurnOutputTokens; + responseText = SanitizeAssistantResponse(responseText, out var warningMessage); + if (!capturePlan && responseText.Length > 0 && !ctx.JsonMode) { if (!Console.IsOutputRedirected) @@ -415,16 +417,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", }); } @@ -555,6 +557,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/TodoPlugin.cs b/src/Infrastructure/Plugins/TodoPlugin.cs index a0dfa5d4..1d15357b 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 From 2366911e80c6c0b42a4915803a9bbee20f2d2b6c Mon Sep 17 00:00:00 2001 From: "Stauffer, Scott" Date: Sat, 1 Aug 2026 07:31:54 -0400 Subject: [PATCH 3/6] fix: allow first read to succeed truncated when over budget - First read in a turn now truncates to budget instead of failing - Subsequent reads still error when budget is exhausted - Prevents agents from being blocked when the first file is simply too large --- .../Plugins/FileSystemPlugin.cs | 37 +++++++++++++++---- .../FileSystemPluginTests.cs | 14 ++++++- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/Infrastructure/Plugins/FileSystemPlugin.cs b/src/Infrastructure/Plugins/FileSystemPlugin.cs index be007240..b9971ef4 100644 --- a/src/Infrastructure/Plugins/FileSystemPlugin.cs +++ b/src/Infrastructure/Plugins/FileSystemPlugin.cs @@ -223,9 +223,22 @@ 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 + { + return PluginResult.Error( + $"Read budget exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars). " + + $"Use grep_file/get_file_summary or narrow read_file ranges. Budget resets next turn."); + } + } _readBudgetUsed += preview.Length; _sessionCache?.RecordRead(resolved, fileInfo); return preview; @@ -266,10 +279,20 @@ 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 + { + content = null; + return PluginResult.Error( + $"Read budget exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars). " + + $"Use grep_file/get_file_summary or narrow read_file ranges. Budget resets next turn."); + } } _readBudgetUsed += built.Length; diff --git a/tests/FuseraftCli.Tests/FileSystemPluginTests.cs b/tests/FuseraftCli.Tests/FileSystemPluginTests.cs index d2428af6..43e72451 100644 --- a/tests/FuseraftCli.Tests/FileSystemPluginTests.cs +++ b/tests/FuseraftCli.Tests/FileSystemPluginTests.cs @@ -488,11 +488,23 @@ 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.DoesNotStartWith("[ERROR]", result); + Assert.Contains("Truncated to fit per-turn read budget", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ReadFile_SubsequentReadAfterBudgetExhausted_ReturnsError() + { + 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.StartsWith("[ERROR]", result); Assert.Contains("budget", result, StringComparison.OrdinalIgnoreCase); } From cd78fa07848fa7a935281ca07d3fdbef5ddeba1a Mon Sep 17 00:00:00 2001 From: "Stauffer, Scott" Date: Sat, 1 Aug 2026 07:40:45 -0400 Subject: [PATCH 4/6] chore: cleanup --- src/Infrastructure/Plugins/TodoPlugin.cs | 2 +- tests/FuseraftCli.Tests/FileSystemPluginTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Infrastructure/Plugins/TodoPlugin.cs b/src/Infrastructure/Plugins/TodoPlugin.cs index 1d15357b..ebba5d59 100644 --- a/src/Infrastructure/Plugins/TodoPlugin.cs +++ b/src/Infrastructure/Plugins/TodoPlugin.cs @@ -51,7 +51,7 @@ public string Write( } catch (JsonException ex) { - return $"[ERROR] Could not parse itemsJson as a JSON array: {ex.Message}. Pass only a JSON array like [{\"content\":\"Example\",\"status\":\"pending\"}]."; + 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."; diff --git a/tests/FuseraftCli.Tests/FileSystemPluginTests.cs b/tests/FuseraftCli.Tests/FileSystemPluginTests.cs index 43e72451..d9ac5008 100644 --- a/tests/FuseraftCli.Tests/FileSystemPluginTests.cs +++ b/tests/FuseraftCli.Tests/FileSystemPluginTests.cs @@ -493,7 +493,7 @@ 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.DoesNotStartWith("[ERROR]", result); + Assert.True(!result.StartsWith("[ERROR]")); Assert.Contains("Truncated to fit per-turn read budget", result, StringComparison.OrdinalIgnoreCase); } From 502bfbae3797f350947a035614d6f0053caf5e5b Mon Sep 17 00:00:00 2001 From: "Stauffer, Scott" Date: Sat, 1 Aug 2026 08:27:33 -0400 Subject: [PATCH 5/6] fix(repl): reset turn-scoped plugin state - clear ITurnResettable plugins at the start of each REPL turn so per-turn state does not leak across prompts - register the file system, shell, and todo plugins with the session context for centralized turn resets --- src/Cli/Commands/Repl/ReplCommand.cs | 30 ++++++++++++++++----- src/Cli/Commands/Repl/ReplSessionContext.cs | 9 +++++++ src/Cli/Commands/Repl/ReplTurn.cs | 1 + 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/Cli/Commands/Repl/ReplCommand.cs b/src/Cli/Commands/Repl/ReplCommand.cs index bbd4cb68..bbba038a 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 a83c4669..682ed187 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 76395fc0..4a74eb4a 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)); From 9f9f16c8514b5b1fdc17532497627a40e355c51e Mon Sep 17 00:00:00 2001 From: "Stauffer, Scott" Date: Sat, 1 Aug 2026 08:39:48 -0400 Subject: [PATCH 6/6] fix(repl): recover from empty replies and read-budget stalls - prevent empty assistant output from ending a turn without a user-facing answer - degrade oversized file reads into compact slices so work can continue in the same turn - keep regression coverage aligned with the non-error follow-up read behavior --- src/Cli/Commands/Repl/ReplTurn.cs | 19 ++++++++++++++++++- .../Plugins/FileSystemPlugin.cs | 18 +++++++++++------- .../FileSystemPluginTests.cs | 7 ++++--- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/Cli/Commands/Repl/ReplTurn.cs b/src/Cli/Commands/Repl/ReplTurn.cs index 4a74eb4a..07e7d396 100644 --- a/src/Cli/Commands/Repl/ReplTurn.cs +++ b/src/Cli/Commands/Repl/ReplTurn.cs @@ -404,6 +404,22 @@ internal static async Task ExecuteAsync( 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) { @@ -519,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, diff --git a/src/Infrastructure/Plugins/FileSystemPlugin.cs b/src/Infrastructure/Plugins/FileSystemPlugin.cs index b9971ef4..663d3ca3 100644 --- a/src/Infrastructure/Plugins/FileSystemPlugin.cs +++ b/src/Infrastructure/Plugins/FileSystemPlugin.cs @@ -234,9 +234,11 @@ public async Task ReadFileAsync( } else { - return PluginResult.Error( - $"Read budget exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars). " + - $"Use grep_file/get_file_summary or narrow read_file ranges. Budget resets next turn."); + 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; @@ -288,10 +290,12 @@ public async Task ReadFileAsync( } else { - content = null; - return PluginResult.Error( - $"Read budget exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars). " + - $"Use grep_file/get_file_summary or narrow read_file ranges. Budget resets next turn."); + 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]; } } diff --git a/tests/FuseraftCli.Tests/FileSystemPluginTests.cs b/tests/FuseraftCli.Tests/FileSystemPluginTests.cs index d9ac5008..3d4665a7 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; @@ -498,15 +499,15 @@ public async Task ReadFile_FirstReadOverBudget_ReturnsTruncatedContent() } [Fact] - public async Task ReadFile_SubsequentReadAfterBudgetExhausted_ReturnsError() + 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.StartsWith("[ERROR]", result); - Assert.Contains("budget", result, StringComparison.OrdinalIgnoreCase); + Assert.False(result.StartsWith("[ERROR]")); + Assert.Contains("Read budget nearly exhausted", result, StringComparison.OrdinalIgnoreCase); } // -----------------------------------------------------------------------