From 1c4374287f63fed8377314cb6440fc4f61479c3e Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Sat, 25 Jul 2026 22:55:53 +0800 Subject: [PATCH] Add the resolver runway to the run-bounds recitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolve cap is the harshest of the silent bounds: an over-cap spawn wave is refused and the run continues, but an over-cap resolve force-stops the whole run (ResolveAttemptsExceeded) — and the decider could not see the count. With the default cap of 1, the model's first resolve already stands at the cliff edge; the next one is the run's death, spent blind. The turn context now carries the plan's MaxResolveAttempts (the same plumbing as MaxTotalSpawns), the RUN BOUNDS recitation renders the attempt count against the effective cap with the fail-safe exit named (stop and leave the conflict to a human — mirroring the resolution verdict's own copy), and the prompt counts resolves off the tape exactly the way SupervisorBounds.PostDecision does, so producer and recitation cannot drift. Zero counters still render nothing. --- .../Deciders/LlmSupervisorDecider.cs | 11 +++-- .../Deciders/SupervisorBoundsRecitation.cs | 11 +++-- .../SupervisorTurnService.Rehydrate.cs | 1 + .../Agents/SupervisorTurnContext.cs | 3 ++ .../Agents/SupervisorBoundsRecitationTests.cs | 48 +++++++++++++++++++ 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/LlmSupervisorDecider.cs b/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/LlmSupervisorDecider.cs index af513a37..71131e80 100644 --- a/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/LlmSupervisorDecider.cs +++ b/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/LlmSupervisorDecider.cs @@ -560,10 +560,13 @@ private static string BuildUserPrompt(SupervisorTurnContext context, string cata builder.AppendLine(budget); } - // P5-3 — the RUN BOUNDS recitation: the no-progress streak + total-spawn count that silently force-stop the - // run (SupervisorBounds). The model previously saw neither and marched into the kill blind. Null while both - // counters are zero ⇒ byte-identical prompt for a fresh run. - if (SupervisorBoundsRecitation.Render(context.NoProgressDecisions, context.MaxNoProgressDecisions, context.TotalSpawnedAgents, context.MaxTotalSpawns) is { } bounds) + // P5-3/P5-5 — the RUN BOUNDS recitation: the no-progress streak + total-spawn count + resolve attempts that + // silently force-stop the run (SupervisorBounds). The model previously saw none of them and marched into + // the kill blind. The resolve count mirrors PostDecision's own tape count exactly (the CURRENT resolve + // isn't on the tape yet, so cap=1 shows 1-of-1 AFTER the first attempt — the moment the next one dies). + // Null while every counter is zero ⇒ byte-identical prompt for a fresh run. + if (SupervisorBoundsRecitation.Render(context.NoProgressDecisions, context.MaxNoProgressDecisions, context.TotalSpawnedAgents, context.MaxTotalSpawns, + context.PriorDecisions.Count(d => d.DecisionKind == SupervisorDecisionKinds.Resolve), context.MaxResolveAttempts) is { } bounds) { builder.AppendLine(); builder.AppendLine(bounds); diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/SupervisorBoundsRecitation.cs b/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/SupervisorBoundsRecitation.cs index c56e2e77..a93607ea 100644 --- a/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/SupervisorBoundsRecitation.cs +++ b/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/SupervisorBoundsRecitation.cs @@ -16,10 +16,10 @@ public static class SupervisorBoundsRecitation /// The block's pinned header — a stable prompt landmark (tests + the model key on it), mirroring . public const string Header = "RUN BOUNDS (recite before deciding — hitting a bound force-stops the run):"; - /// Render the bounds block for the decider's prompt, or null when both counters are zero (nothing is at risk yet). - public static string? Render(int noProgressDecisions, int maxNoProgressDecisions, int totalSpawnedAgents, int? maxTotalSpawns) + /// Render the bounds block for the decider's prompt, or null when every counter is zero (nothing is at risk yet). + public static string? Render(int noProgressDecisions, int maxNoProgressDecisions, int totalSpawnedAgents, int? maxTotalSpawns, int resolveAttempts = 0, int? maxResolveAttempts = null) { - if (noProgressDecisions <= 0 && totalSpawnedAgents <= 0) return null; + if (noProgressDecisions <= 0 && totalSpawnedAgents <= 0 && resolveAttempts <= 0) return null; var builder = new StringBuilder(Header); @@ -34,6 +34,11 @@ public static class SupervisorBoundsRecitation if (totalSpawnedAgents > 0) builder.AppendLine().Append($"- agents spawned: {totalSpawnedAgents} of {maxTotalSpawns ?? SupervisorLane.DefaultMaxTotalSpawns} total-spawn cap (every spawn fan-out member and every retry counts one; a wave that would exceed the cap is refused)."); + // P5-5: the resolver runway — a resolve PAST the cap doesn't get refused, it force-stops the whole run + // (ResolveAttemptsExceeded), so the model must know the count before spending the run's life on one more. + if (resolveAttempts > 0) + builder.AppendLine().Append($"- resolve attempts: {resolveAttempts} of {maxResolveAttempts ?? SupervisorLane.DefaultMaxResolveAttempts} resolve cap — a resolve past the cap force-stops this run. If the reconciliation still is not VERIFIED within the cap, stop and leave the conflict to a human."); + return builder.ToString(); } } diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs index 9bf3f686..2e9c0601 100644 --- a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs +++ b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs @@ -132,6 +132,7 @@ public async Task RehydrateFromDecisionLogAsync(Guid supe BrainPlaneSpendByKind = brainPlaneSpend.ByKind, MaxCostUsd = plan.MaxCostUsd, MaxTotalSpawns = plan.MaxTotalSpawns, + MaxResolveAttempts = plan.MaxResolveAttempts, NoProgressDecisions = FoldNoProgressDecisions(priorDecisions), MaxNoProgressDecisions = plan.MaxNoProgressDecisions, ApprovalPolicy = plan.ApprovalPolicy, diff --git a/backend/src/CodeSpace.Messages/Agents/SupervisorTurnContext.cs b/backend/src/CodeSpace.Messages/Agents/SupervisorTurnContext.cs index 01621973..13f54cf5 100644 --- a/backend/src/CodeSpace.Messages/Agents/SupervisorTurnContext.cs +++ b/backend/src/CodeSpace.Messages/Agents/SupervisorTurnContext.cs @@ -86,6 +86,9 @@ public sealed record SupervisorTurnContext /// W-hard — the run's total-spawn cap (carried from SupervisorGoalPlan.MaxTotalSpawns, same plumbing as ): the budget ledger's per-attempt estimate divisor (cap ÷ spawns = the natural reservation slice). Null on legacy contexts; readers fall back to the lane default. public int? MaxTotalSpawns { get; init; } + /// P5-5 — the run's resolve-attempt cap (carried from SupervisorGoalPlan.MaxResolveAttempts, same plumbing as ) so the RUN BOUNDS recitation can show the resolver runway — a resolve past the cap force-stops the run. Null on legacy contexts; readers fall back to the lane default. + public int? MaxResolveAttempts { get; init; } + private static readonly IReadOnlyDictionary EmptySpendByKind = new Dictionary(); /// diff --git a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsRecitationTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsRecitationTests.cs index 2a467292..7d74c9c5 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsRecitationTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsRecitationTests.cs @@ -62,6 +62,35 @@ public void A_legacy_context_without_a_cap_falls_back_to_the_lane_default() .ShouldContain($"5 of {SupervisorLane.DefaultMaxTotalSpawns} total-spawn cap", Case.Sensitive, "readers fall back to the lane default — the recitation mirrors them"); } + // ── P5-5: the resolver runway ─────────────────────────────────────────────────── + + [Fact] + public void The_resolve_line_names_the_count_the_cap_and_the_human_fallback() + { + var block = SupervisorBoundsRecitation.Render(0, 8, 0, 50, resolveAttempts: 1, maxResolveAttempts: 2); + + block.ShouldNotBeNull("a spent resolve attempt alone warrants the block — the next one past the cap kills the run"); + block!.ShouldContain("resolve attempts: 1 of 2 resolve cap", Case.Sensitive); + block.ShouldContain("a resolve past the cap force-stops this run", Case.Sensitive, "unlike a spawn wave, an over-cap resolve is not refused — it is the run's death; the model must know which"); + block.ShouldContain("stop and leave the conflict to a human", Case.Sensitive, "the fail-safe exit is named, mirroring the resolution-verdict copy"); + block.ShouldNotContain("no-progress decisions", Case.Sensitive); + block.ShouldNotContain("agents spawned", Case.Sensitive); + } + + [Fact] + public void A_legacy_context_without_a_resolve_cap_falls_back_to_the_lane_default() + { + SupervisorBoundsRecitation.Render(0, 8, 0, 50, resolveAttempts: 1, maxResolveAttempts: null)! + .ShouldContain($"resolve attempts: 1 of {SupervisorLane.DefaultMaxResolveAttempts} resolve cap", Case.Sensitive); + } + + [Fact] + public void Zero_resolve_attempts_render_no_resolve_line() + { + SupervisorBoundsRecitation.Render(6, 8, 0, 50)! + .ShouldNotContain("resolve attempts", Case.Sensitive, "no resolve on the tape → no resolver line, the default params keep every P5-3 caller byte-identical"); + } + [Fact] public void The_header_is_pinned() { @@ -91,4 +120,23 @@ public void A_fresh_runs_prompt_has_no_bounds_block() LlmSupervisorDecider.BuildUserPromptForTest(new SupervisorTurnContext { Goal = "ship it", TurnNumber = 0, PriorDecisions = Array.Empty() }) .ShouldNotContain("RUN BOUNDS", Case.Sensitive, "byte-identical prompt while nothing is at risk"); } + + [Fact] + public void The_user_prompt_counts_resolves_off_the_tape_exactly_like_the_bound_does() + { + // The prompt's count mirrors SupervisorBounds.PostDecision's own tape count (prior Resolve decisions) — + // producer and recitation can't drift because both read the same rows the same way. + var resolve = new SupervisorPriorDecision + { + Id = Guid.NewGuid(), Sequence = 3, DecisionKind = SupervisorDecisionKinds.Resolve, Status = SupervisorDecisionStatus.Succeeded, + PayloadJson = "{}", OutcomeJson = """{"agentRunIds":[],"agentCount":0}""", + }; + + var prompt = LlmSupervisorDecider.BuildUserPromptForTest(new SupervisorTurnContext + { + Goal = "ship it", TurnNumber = 4, PriorDecisions = new[] { resolve }, MaxResolveAttempts = 2, + }); + + prompt.ShouldContain("resolve attempts: 1 of 2 resolve cap", Case.Sensitive); + } }