diff --git a/src/MandoCode/Components/App.razor b/src/MandoCode/Components/App.razor index 531f541..cbf91f4 100644 --- a/src/MandoCode/Components/App.razor +++ b/src/MandoCode/Components/App.razor @@ -939,13 +939,28 @@ return; } + if (command == "compact") + { + if (await AI.CompactHistoryAsync()) + { + try { SessionResumeStore.Save(ProjectRoot.ProjectRoot, AI.ExportHistoryJson()); } + catch { /* persistence must never break the chat */ } + AnsiConsole.MarkupLine("[green]Conversation context compacted into a recap. The visible transcript is unchanged.[/]"); + } + else + { + AnsiConsole.MarkupLine("[dim]Not enough conversation context to compact yet.[/]"); + } + continue; + } + if (command == "clear") { await AI.ClearHistoryAsync(); StateMachine.ClearHistory(); SessionResumeStore.Delete(ProjectRoot.ProjectRoot); // cleared means cleared — --continue too Console.Clear(); - AnsiConsole.WriteLine("Conversation cleared."); + AnsiConsole.MarkupLine("[yellow]Conversation context wiped completely. Start a new conversation.[/]"); continue; } @@ -1009,7 +1024,8 @@ table.AddRow("/plan ", "Force a step-by-step plan (no goal shows an unfinished plan)"); table.AddRow("/plan-resume", "Continue an unfinished plan"); table.AddRow("/plan-discard", "Forget an unfinished plan"); - table.AddRow("/clear", "Clear conversation history"); + table.AddRow("/compact", "Compress context into a recap; keeps the transcript"); + table.AddRow("/clear", "Wipe all conversation context and start fresh"); table.AddRow("/exit", "Exit MandoCode"); AnsiConsole.Write(table); AnsiConsole.WriteLine(); @@ -2044,15 +2060,6 @@ return; } - if (!PlanRunners.SupportsResume) - { - AnsiConsole.MarkupLine( - "[yellow]Plans are only resumable on the workflow planner.[/]"); - AnsiConsole.MarkupLine("[dim]Enable it with:[/] /config set planner workflow"); - AnsiConsole.WriteLine(); - return; - } - if (arg == "discard") { PlanRunners.DiscardResumable(); diff --git a/src/MandoCode/Components/HelpDisplay.razor b/src/MandoCode/Components/HelpDisplay.razor index aae99f8..9b3843f 100644 --- a/src/MandoCode/Components/HelpDisplay.razor +++ b/src/MandoCode/Components/HelpDisplay.razor @@ -27,8 +27,10 @@ + + - + diff --git a/src/MandoCode/MandoCode.csproj b/src/MandoCode/MandoCode.csproj index f2e1f73..3ef64c8 100644 --- a/src/MandoCode/MandoCode.csproj +++ b/src/MandoCode/MandoCode.csproj @@ -23,27 +23,6 @@ README.md MC.png MC.ico - -v0.13.0 — It streams now (and stops giving up on big tasks) - -This release is about long, heavy tasks no longer *looking* broken. Building real projects with MandoCode, I kept hitting the same thing: it would stop partway through writing a big file and announce that the model had "stalled" — when the model was actually working just fine. The root cause was that responses weren't streamed, so the safety timer meant to catch a genuinely-stuck model was flying blind. MandoCode now streams responses, which fixes it at the source — plus a couple of changes to make sure the fix actually reaches you. - -New: - -- It streams. Responses now arrive as they're written, instead of in one lump at the end. It feels snappier — but more importantly, MandoCode can finally tell "still working on a long answer" from "actually stuck," so it stops cancelling healthy work partway through. On by default for every model; turn it off anytime with /config set streaming off. Before flipping it on by default I validated it live against real models, both cloud and local. - -- It tells you when there's an update. MandoCode now checks for a newer version on startup and shows a one-line nudge with the exact command to update — so you're never quietly stuck on an old build without knowing one exists. - -Fixed: - -- No more "stalled" on a big file. A large response — a whole game file, a long reasoned answer — used to get cancelled and mislabeled as a stall, every single time, with no way to retry past it. Fixed at the root by streaming, with a more generous safety margin as backup for everything else. - -- No more spinning on the same files. A step meant to create a file could instead re-read the files it already had, over and over, until it ran out of time without ever writing anything. It now notices it has already seen a file and gets on with the actual work. - -- Better defaults reach you, not just new installs. When I improve a default setting, your existing install now picks it up automatically instead of the change only helping people who install fresh. - -Every change is covered by automated tests — the suite grew to 486 checks. - diff --git a/src/MandoCode/Models/ConfigKeySetter.cs b/src/MandoCode/Models/ConfigKeySetter.cs index 0c18366..f054bfc 100644 --- a/src/MandoCode/Models/ConfigKeySetter.cs +++ b/src/MandoCode/Models/ConfigKeySetter.cs @@ -144,27 +144,7 @@ public static SetResult TrySet(MandoCodeConfig config, string key, string value) case "planner": case "plannerengine": - // Deliberately separate from taskPlanning, which decides whether there is a - // planner at all (it gates registering propose_plan). Overloading that key - // would make "planning off" and "old engine" the same state and render any - // A/B between the two engines uninterpretable. - var planner = value.Trim().ToLowerInvariant(); - if (planner is "default" or "auto" or "clear") - { - config.PlannerEngine = null; - return new(true, "✓ Planner engine reset to this build's default", ApplyScope.KernelRebuild); - } - if (planner == MandoCodeConfig.PlannerEngineLegacy) - { - config.PlannerEngine = planner; - return new(true, "✓ Planner engine set to: legacy", ApplyScope.KernelRebuild); - } - if (planner == MandoCodeConfig.PlannerEngineWorkflow) - { - config.PlannerEngine = planner; - return new(true, "✓ Planner engine set to: workflow (experimental)", ApplyScope.KernelRebuild); - } - return Fail("Error: Value must be 'legacy', 'workflow', or 'default'"); + return Fail("Error: The workflow planner is always enabled and cannot be changed."); case "streaming": case "responsestreaming": @@ -273,7 +253,6 @@ public static string DescribeKeys(MandoCodeConfig config) => maxContinuations {config.MaxAutoContinuations} ({MandoCodeConfig.MinMaxAutoContinuations}-{MandoCodeConfig.MaxMaxAutoContinuations}) renderTimeout {config.MarkdownRenderTimeoutSeconds}s ({MandoCodeConfig.MinMarkdownRenderTimeoutSeconds}-{MandoCodeConfig.MaxMarkdownRenderTimeoutSeconds}) taskPlanning {config.EnableTaskPlanning} - planner {config.PlannerEngine ?? "default"} (legacy | workflow | default) diffApprovals {config.EnableDiffApprovals} webSearch {config.EnableWebSearch} tavilyKey {(string.IsNullOrWhiteSpace(config.TavilyApiKey) ? "not set" : MandoCodeConfig.MaskApiKey(config.TavilyApiKey))} (Tavily API key for reliable web search — free at https://app.tavily.com; "clear" to remove) diff --git a/src/MandoCode/Models/MandoCodeConfig.cs b/src/MandoCode/Models/MandoCodeConfig.cs index 7b2067c..7f03ad6 100644 --- a/src/MandoCode/Models/MandoCodeConfig.cs +++ b/src/MandoCode/Models/MandoCodeConfig.cs @@ -304,31 +304,6 @@ public static int RecommendedContextLength(string? modelTag) [JsonPropertyName("enableTaskPlanning")] public bool EnableTaskPlanning { get; set; } = true; - /// Planner engine: the original in-tool-call runner. - public const string PlannerEngineLegacy = "legacy"; - - /// Planner engine: the MAF workflow graph (lands in a later phase). - public const string PlannerEngineWorkflow = "workflow"; - - /// - /// Which planner engine to use, or null for whatever this build defaults to. - /// Distinct from , which decides whether there is a planner - /// at all. - /// - /// - /// Nullable on purpose, and it must stay that way. When the default eventually flips to - /// , a null here still means "follow the build" while an - /// explicit "legacy" still means "the user chose this" — so the flip is a one-line change with - /// no config migration and no version bump. With a non-nullable default the two states are - /// indistinguishable, and Migrate() would have to guess, exactly as it already has to - /// for ModelResponseTimeoutSeconds. Also written only when set, so older builds — whose reader - /// has no UnmappedMemberHandling and whose Save() reserializes the whole object — don't - /// silently drop a key they never knew about. - /// - [JsonPropertyName("planner")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? PlannerEngine { get; set; } - /// /// Enable fallback parsing for function calls output as JSON text. /// Some local models output function calls as text instead of proper tool calls. diff --git a/src/MandoCode/Models/SlashCommands.cs b/src/MandoCode/Models/SlashCommands.cs index a52bb41..1949b7f 100644 --- a/src/MandoCode/Models/SlashCommands.cs +++ b/src/MandoCode/Models/SlashCommands.cs @@ -24,7 +24,8 @@ public static class SlashCommands { "/plan", "Force planning for a goal, or show an unfinished plan" }, { "/plan-resume", "Continue an unfinished plan where it left off" }, { "/plan-discard", "Forget an unfinished plan" }, - { "/clear", "Clear conversation history" }, + { "/compact", "Compress conversation context into a recap (keeps this transcript)" }, + { "/clear", "Wipe all conversation context and start fresh" }, { "/learn", "Learn about LLMs and local AI models" }, { "/retry", "Retry Ollama connection" }, { "/music", "Play music" }, diff --git a/src/MandoCode/Program.cs b/src/MandoCode/Program.cs index a731b1a..0d76cf5 100644 --- a/src/MandoCode/Program.cs +++ b/src/MandoCode/Program.cs @@ -164,15 +164,13 @@ static async Task Main(string[] args) return new TaskPlannerService(aiService, cfg); }); - // Which engine actually runs a plan is decided per plan, not here — see - // PlanRunnerSelector, which re-reads the `planner` key so the choice can be flipped - // mid-session without losing history. + // Plans always run through the workflow runner; TaskPlannerService above remains the + // decision heuristic and proposal mapper. services.AddSingleton(provider => new AiServicePlanStepExecutor(provider.GetRequiredService())); services.AddSingleton(provider => new PlanRunnerSelector( provider.GetRequiredService(), - provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService())); diff --git a/src/MandoCode/Services/Ai/AIService.cs b/src/MandoCode/Services/Ai/AIService.cs index 9210f3e..f9e945b 100644 --- a/src/MandoCode/Services/Ai/AIService.cs +++ b/src/MandoCode/Services/Ai/AIService.cs @@ -1920,6 +1920,35 @@ private async Task CompactChatHistoryAsync() finally { _historyLock.Release(); } } + /// + /// Compacts the completed conversation into a deterministic recap without clearing the visible + /// transcript, approval state, or token totals. This is the user-invoked counterpart to the + /// automatic overflow recovery path: it gives the next request a smaller context while keeping + /// the important conversation trail available to the model. + /// + /// true when there was enough conversation history to compact. + public async Task CompactHistoryAsync() + { + await _historyLock.WaitAsync(); + try + { + // A system prompt plus zero or one conversational message has nothing meaningful to + // reduce. Keeping it verbatim is more faithful than wrapping it in a recap. + if (_chatHistory.Count <= 2) return false; + + var recap = SynthesizeHistorySummary(_chatHistory, startIndex: 1, maxChars: 6000); + if (string.IsNullOrWhiteSpace(recap) || recap == "(no prior activity captured)") return false; + + _chatHistory.Clear(); + _chatHistory.Add(new ChatMessage(ChatRole.System, _systemPrompt)); + _chatHistory.Add(new ChatMessage(ChatRole.User, + "[Conversation recap — prior turns were compacted at the user's request. " + + "Use this as context and continue naturally: ]\n" + recap)); + return true; + } + finally { _historyLock.Release(); } + } + /// /// Exposes the token tracker for external consumers (e.g., App.razor display). /// diff --git a/src/MandoCode/Services/Ai/Planning/PlanRunnerSelector.cs b/src/MandoCode/Services/Ai/Planning/PlanRunnerSelector.cs index 48caa74..97f4a34 100644 --- a/src/MandoCode/Services/Ai/Planning/PlanRunnerSelector.cs +++ b/src/MandoCode/Services/Ai/Planning/PlanRunnerSelector.cs @@ -3,22 +3,15 @@ namespace MandoCode.Services; /// -/// Resolves which plan engine to use, re-reading the planner config key on every access, and -/// records a running plan's progress so it can be resumed after an interruption. +/// Owns the workflow plan runner and records a running plan's progress so it can be resumed after +/// an interruption. /// /// -/// Not a plain DI registration because planner is KernelRebuild-scoped: it is meant to -/// take effect on the next message without losing history, so a singleton captured at startup would -/// silently ignore /config set planner workflow. Re-reading also makes an A/B practical — -/// same session, same history, flip the key, re-run the same prompt. -/// /// The workflow runner is cached once created; it holds no per-run state (each run builds its own /// graph over its own context). -/// /// public sealed class PlanRunnerSelector( MandoCodeConfig config, - TaskPlannerService legacyRunner, IPlanStepExecutor stepExecutor, PlanHandoff? planHandoff = null, ProjectRootAccessor? projectRoot = null, @@ -26,9 +19,8 @@ public sealed class PlanRunnerSelector( { private WorkflowPlanRunner? _workflowRunner; - /// True when the workflow engine is currently selected. - public bool UsingWorkflowEngine => string.Equals( - config.PlannerEngine, MandoCodeConfig.PlannerEngineWorkflow, StringComparison.OrdinalIgnoreCase); + /// The workflow planner is the only supported plan runner. + public bool UsingWorkflowEngine => true; /// /// True when progress is recorded for resume. Only the workflow engine reports its state, so @@ -37,9 +29,7 @@ public sealed class PlanRunnerSelector( public bool SupportsResume => UsingWorkflowEngine && projectRoot != null; /// The engine to run the next plan with. - public IPlanRunner Current => UsingWorkflowEngine - ? _workflowRunner ??= new WorkflowPlanRunner(stepExecutor, planHandoff, RecordProgress) - : legacyRunner; + public IPlanRunner Current => _workflowRunner ??= new WorkflowPlanRunner(stepExecutor, planHandoff, RecordProgress); /// The plan recorded for this project that could be resumed, or null. /// diff --git a/src/MandoCode/Services/Ai/Planning/WorkflowPlanRunner.cs b/src/MandoCode/Services/Ai/Planning/WorkflowPlanRunner.cs index 0928eb2..d7396fb 100644 --- a/src/MandoCode/Services/Ai/Planning/WorkflowPlanRunner.cs +++ b/src/MandoCode/Services/Ai/Planning/WorkflowPlanRunner.cs @@ -10,9 +10,8 @@ namespace MandoCode.Services; /// /// /// -/// Drop-in alternative to behind the planner config key, so -/// both engines can be A/B'd against real local models from the same session. It emits the same -/// stream, so neither front-end changes. +/// The application's plan runner. It emits the standard stream, +/// so neither front-end needs workflow-specific handling. /// /// /// Topology is fixed — intake, step runner, triage, finalizer — regardless of how many steps the diff --git a/src/MandoCode/docs/TaskPlanner.md b/src/MandoCode/docs/TaskPlanner.md index 558eafb..1fa0d13 100644 --- a/src/MandoCode/docs/TaskPlanner.md +++ b/src/MandoCode/docs/TaskPlanner.md @@ -311,7 +311,7 @@ See the main [README](../../../README.md#diff-approvals) for user-facing documen ## MAF workflow planner -The optional workflow planner is built on `Microsoft.Agents.AI.Workflows`. Its first architectural +The workflow planner is built on `Microsoft.Agents.AI.Workflows`. Its first architectural change was removing one root cause: the old planner executed the whole plan inside the `propose_plan` tool call. Because the outer model turn was still open, it could not observe the work, treated the returned summary as "not started yet," and repeated it. `propose_plan` now returns a @@ -328,13 +328,13 @@ too: it routes via `handoff_to_*` tool calls that cannot be forced, the worst po | Phase | Scope | State | |---|---|---| | 0 | Spike the real 1.19.0 assembly | done | -| 1 | Freeze identity / checkpoint envelope / config key; extract `IPlanRunner` + `IPlanStepExecutor` | done | +| 1 | Freeze identity / checkpoint envelope; extract `IPlanRunner` + `IPlanStepExecutor` | done | | 2 | Un-nest: `propose_plan` returns a receipt, the host runs the plan after the turn drains | done | -| 3 | The graph behind `planner=workflow`: fixed topology, triage owns plan state | done | +| 3 | Fixed workflow graph: triage owns plan state | done | | 3b | Show step instructions and support coherent pre-execution edits | done | | 4 | Checkpointing, resume, discard, and native Desktop recovery actions | done | | 5 | Retry, replan approval, deterministic `/plan `, and truthful partial status | done | -| 6 | Flip the `planner` default, then delete the legacy runner | deferred pending release soak | +| 6 | Make the workflow runner the only supported engine | done | Spike findings worth keeping: @@ -362,11 +362,8 @@ Graph-authoring notes (all learned the hard way against the real assembly): - `InProcessExecution.RunStreamingAsync`'s third positional parameter is `sessionId`, not the cancellation token. -`planner` selects the engine (`legacy` | `workflow` | `default`) and is re-read per plan by -`PlanRunnerSelector`, so it can be flipped mid-session without losing history — which is what makes -an honest A/B against a local model practical. `PlanRunnerBehaviorTests` runs every behavioral case -against **both** engines: while both are selectable, any divergence would make that A/B -uninterpretable, since a behavior difference would be indistinguishable from a model difference. +`PlanRunnerSelector` always selects the workflow runner. There is no `planner` configuration key or +legacy-engine fallback; `PlanRunnerBehaviorTests` exercises the production workflow behavior. Executor and agent identities are fixed in `PlanExecutorIds` and must not drift: MAF derives workflow-executor identity from both the agent's `Id` and `Name`, and a checkpoint written under one diff --git a/tests/MandoCode.Tests/PlanRunnerBehaviorTests.cs b/tests/MandoCode.Tests/PlanRunnerBehaviorTests.cs index f9cd7bc..a5b9291 100644 --- a/tests/MandoCode.Tests/PlanRunnerBehaviorTests.cs +++ b/tests/MandoCode.Tests/PlanRunnerBehaviorTests.cs @@ -14,11 +14,10 @@ namespace MandoCode.Tests; /// public class PlanRunnerBehaviorTests { - public static TheoryData Engines => new() { "legacy", "workflow" }; + public static TheoryData Engines => new() { "workflow" }; private static IPlanRunner MakeRunner(string engine, IPlanStepExecutor executor) => engine switch { - "legacy" => new TaskPlannerService(executor, new MandoCodeConfig()), "workflow" => new WorkflowPlanRunner(executor), _ => throw new ArgumentOutOfRangeException(nameof(engine), engine, "unknown planner engine"), }; diff --git a/tests/MandoCode.Tests/PlannerEngineConfigTests.cs b/tests/MandoCode.Tests/PlannerEngineConfigTests.cs index 6cd6dd0..fc03a50 100644 --- a/tests/MandoCode.Tests/PlannerEngineConfigTests.cs +++ b/tests/MandoCode.Tests/PlannerEngineConfigTests.cs @@ -1,109 +1,22 @@ -using System.Text.Json; using Xunit; using MandoCode.Models; namespace MandoCode.Tests; /// -/// Tests the `planner` config key. Its nullability is load-bearing, not incidental: when the -/// default eventually flips to the workflow engine, null must still mean "follow the build" while -/// an explicit "legacy" still means "the user chose this". With a non-nullable default those two -/// states are indistinguishable and the flip becomes a guess — the same guess Migrate() already -/// has to make for ModelResponseTimeoutSeconds. +/// The workflow planner is the product default and is intentionally not a configurable engine. /// public class PlannerEngineConfigTests { - [Fact] - public void DefaultsToNull_MeaningBuildDefault() - { - Assert.Null(new MandoCodeConfig().PlannerEngine); - } - - [Fact] - public void IsNotWritten_WhenUnset() - { - // Older builds reserialize the whole config on save and have no UnmappedMemberHandling, - // so emitting a null key would be noise they'd drop anyway. - var json = JsonSerializer.Serialize(new MandoCodeConfig()); - Assert.DoesNotContain("\"planner\"", json); - } - - [Fact] - public void RoundTrips_WhenSet() - { - var json = JsonSerializer.Serialize( - new MandoCodeConfig { PlannerEngine = MandoCodeConfig.PlannerEngineLegacy }); - - Assert.Contains("\"planner\"", json); - Assert.Equal( - MandoCodeConfig.PlannerEngineLegacy, - JsonSerializer.Deserialize(json)!.PlannerEngine); - } - - [Fact] - public void AbsentKey_DeserializesToNull() - { - var config = JsonSerializer.Deserialize("""{"modelName":"qwen3:8b"}""")!; - Assert.Null(config.PlannerEngine); - } - - [Fact] - public void TrySet_AcceptsLegacy() - { - var config = new MandoCodeConfig(); - var result = ConfigKeySetter.TrySet(config, "planner", "legacy"); - - Assert.True(result.Ok); - Assert.Equal(MandoCodeConfig.PlannerEngineLegacy, config.PlannerEngine); - Assert.Equal(ConfigKeySetter.ApplyScope.KernelRebuild, result.Scope); - } - [Theory] + [InlineData("legacy")] + [InlineData("workflow")] [InlineData("default")] - [InlineData("auto")] - [InlineData("clear")] - public void TrySet_ResetsToBuildDefault(string value) - { - var config = new MandoCodeConfig { PlannerEngine = MandoCodeConfig.PlannerEngineLegacy }; - var result = ConfigKeySetter.TrySet(config, "planner", value); - - Assert.True(result.Ok); - Assert.Null(config.PlannerEngine); - } - - [Fact] - public void TrySet_AcceptsWorkflow() + public void TrySet_RejectsPlannerEngineSelection(string value) { - var config = new MandoCodeConfig(); - var result = ConfigKeySetter.TrySet(config, "planner", "workflow"); - - Assert.True(result.Ok); - Assert.Equal(MandoCodeConfig.PlannerEngineWorkflow, config.PlannerEngine); - Assert.Equal(ConfigKeySetter.ApplyScope.KernelRebuild, result.Scope); - } - - [Fact] - public void TrySet_RejectsUnknownValues() - { - var config = new MandoCodeConfig(); - Assert.False(ConfigKeySetter.TrySet(config, "planner", "magentic").Ok); - Assert.Null(config.PlannerEngine); - } - - [Fact] - public void PlannerKey_IsIndependentOfEnableTaskPlanning() - { - // Overloading enableTaskPlanning as the engine switch would make "planning off" and - // "old engine" the same state, and render any A/B between engines uninterpretable. - var config = new MandoCodeConfig(); - ConfigKeySetter.TrySet(config, "planner", "legacy"); - - Assert.True(config.EnableTaskPlanning); - Assert.Equal(MandoCodeConfig.PlannerEngineLegacy, config.PlannerEngine); - - ConfigKeySetter.TrySet(config, "taskPlanning", "false"); + var result = ConfigKeySetter.TrySet(new MandoCodeConfig(), "planner", value); - Assert.False(config.EnableTaskPlanning); - Assert.Equal(MandoCodeConfig.PlannerEngineLegacy, config.PlannerEngine); + Assert.False(result.Ok); + Assert.Contains("always enabled", result.Message); } }