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
29 changes: 18 additions & 11 deletions src/MandoCode/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -1009,7 +1024,8 @@
table.AddRow("/plan <goal>", "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();
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion src/MandoCode/Components/HelpDisplay.razor
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
<Markup Content="/music-stop, /music-pause, /music-next, /music-vol" Foreground="Color.DeepSkyBlue1" />
<Markup Content="" />
<Markup Content="/music-lofi, /music-synthwave, /music-list" Foreground="Color.DeepSkyBlue1" />
<Markup Content="/compact" Foreground="Color.DeepSkyBlue1" />
<Markup Content="- Compress context into a recap; keeps the transcript" Foreground="Color.Green" />
<Markup Content="/clear" Foreground="Color.DeepSkyBlue1" />
<Markup Content="- Clear conversation history" Foreground="Color.Green" />
<Markup Content="- Wipe all conversation context and start fresh" Foreground="Color.Green" />
<Markup Content="/exit" Foreground="Color.DeepSkyBlue1" />
<Markup Content="- Exit MandoCode" Foreground="Color.Green" />
</Grid>
Expand Down
21 changes: 0 additions & 21 deletions src/MandoCode/MandoCode.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,6 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageIcon>MC.png</PackageIcon>
<ApplicationIcon>MC.ico</ApplicationIcon>
<PackageReleaseNotes>
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.
</PackageReleaseNotes>
</PropertyGroup>

<ItemGroup>
Expand Down
23 changes: 1 addition & 22 deletions src/MandoCode/Models/ConfigKeySetter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 0 additions & 25 deletions src/MandoCode/Models/MandoCodeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,31 +304,6 @@ public static int RecommendedContextLength(string? modelTag)
[JsonPropertyName("enableTaskPlanning")]
public bool EnableTaskPlanning { get; set; } = true;

/// <summary>Planner engine: the original in-tool-call runner.</summary>
public const string PlannerEngineLegacy = "legacy";

/// <summary>Planner engine: the MAF workflow graph (lands in a later phase).</summary>
public const string PlannerEngineWorkflow = "workflow";

/// <summary>
/// Which planner engine to use, or <c>null</c> for whatever this build defaults to.
/// Distinct from <see cref="EnableTaskPlanning"/>, which decides whether there is a planner
/// at all.
/// </summary>
/// <remarks>
/// Nullable on purpose, and it must stay that way. When the default eventually flips to
/// <see cref="PlannerEngineWorkflow"/>, 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 <c>Migrate()</c> 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.
/// </remarks>
[JsonPropertyName("planner")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PlannerEngine { get; set; }

/// <summary>
/// Enable fallback parsing for function calls output as JSON text.
/// Some local models output function calls as text instead of proper tool calls.
Expand Down
3 changes: 2 additions & 1 deletion src/MandoCode/Models/SlashCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
6 changes: 2 additions & 4 deletions src/MandoCode/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IPlanStepExecutor>(provider =>
new AiServicePlanStepExecutor(provider.GetRequiredService<AIService>()));

services.AddSingleton(provider => new PlanRunnerSelector(
provider.GetRequiredService<MandoCodeConfig>(),
provider.GetRequiredService<TaskPlannerService>(),
provider.GetRequiredService<IPlanStepExecutor>(),
provider.GetRequiredService<PlanHandoff>(),
provider.GetRequiredService<ProjectRootAccessor>()));
Expand Down
29 changes: 29 additions & 0 deletions src/MandoCode/Services/Ai/AIService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1920,6 +1920,35 @@ private async Task CompactChatHistoryAsync()
finally { _historyLock.Release(); }
}

/// <summary>
/// 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.
/// </summary>
/// <returns><c>true</c> when there was enough conversation history to compact.</returns>
public async Task<bool> 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(); }
}

/// <summary>
/// Exposes the token tracker for external consumers (e.g., App.razor display).
/// </summary>
Expand Down
20 changes: 5 additions & 15 deletions src/MandoCode/Services/Ai/Planning/PlanRunnerSelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,24 @@
namespace MandoCode.Services;

/// <summary>
/// Resolves which plan engine to use, re-reading the <c>planner</c> 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.
/// </summary>
/// <remarks>
/// Not a plain DI registration because <c>planner</c> is <c>KernelRebuild</c>-scoped: it is meant to
/// take effect on the next message without losing history, so a singleton captured at startup would
/// silently ignore <c>/config set planner workflow</c>. Re-reading also makes an A/B practical —
/// same session, same history, flip the key, re-run the same prompt.
/// <para>
/// The workflow runner is cached once created; it holds no per-run state (each run builds its own
/// graph over its own context).
/// </para>
/// </remarks>
public sealed class PlanRunnerSelector(
MandoCodeConfig config,
TaskPlannerService legacyRunner,
IPlanStepExecutor stepExecutor,
PlanHandoff? planHandoff = null,
ProjectRootAccessor? projectRoot = null,
string? checkpointId = null)
{
private WorkflowPlanRunner? _workflowRunner;

/// <summary>True when the workflow engine is currently selected.</summary>
public bool UsingWorkflowEngine => string.Equals(
config.PlannerEngine, MandoCodeConfig.PlannerEngineWorkflow, StringComparison.OrdinalIgnoreCase);
/// <summary>The workflow planner is the only supported plan runner.</summary>
public bool UsingWorkflowEngine => true;

/// <summary>
/// True when progress is recorded for resume. Only the workflow engine reports its state, so
Expand All @@ -37,9 +29,7 @@ public sealed class PlanRunnerSelector(
public bool SupportsResume => UsingWorkflowEngine && projectRoot != null;

/// <summary>The engine to run the next plan with.</summary>
public IPlanRunner Current => UsingWorkflowEngine
? _workflowRunner ??= new WorkflowPlanRunner(stepExecutor, planHandoff, RecordProgress)
: legacyRunner;
public IPlanRunner Current => _workflowRunner ??= new WorkflowPlanRunner(stepExecutor, planHandoff, RecordProgress);

/// <summary>The plan recorded for this project that could be resumed, or <c>null</c>.</summary>
/// <param name="refusal">
Expand Down
5 changes: 2 additions & 3 deletions src/MandoCode/Services/Ai/Planning/WorkflowPlanRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ namespace MandoCode.Services;
/// </summary>
/// <remarks>
/// <para>
/// Drop-in alternative to <see cref="TaskPlannerService"/> behind the <c>planner</c> config key, so
/// both engines can be A/B'd against real local models from the same session. It emits the same
/// <see cref="TaskProgressEvent"/> stream, so neither front-end changes.
/// The application's plan runner. It emits the standard <see cref="TaskProgressEvent"/> stream,
/// so neither front-end needs workflow-specific handling.
/// </para>
/// <para>
/// Topology is fixed — intake, step runner, triage, finalizer — regardless of how many steps the
Expand Down
15 changes: 6 additions & 9 deletions src/MandoCode/docs/TaskPlanner.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <goal>`, 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:

Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions tests/MandoCode.Tests/PlanRunnerBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@ namespace MandoCode.Tests;
/// </summary>
public class PlanRunnerBehaviorTests
{
public static TheoryData<string> Engines => new() { "legacy", "workflow" };
public static TheoryData<string> 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"),
};
Expand Down
Loading