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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ public static class SupervisorBoundsRecitation
/// <summary>The block's pinned header — a stable prompt landmark (tests + the model key on it), mirroring <see cref="SupervisorBudgetRecitation.Header"/>.</summary>
public const string Header = "RUN BOUNDS (recite before deciding — hitting a bound force-stops the run):";

/// <summary>Render the bounds block for the decider's prompt, or null when both counters are zero (nothing is at risk yet).</summary>
public static string? Render(int noProgressDecisions, int maxNoProgressDecisions, int totalSpawnedAgents, int? maxTotalSpawns)
/// <summary>Render the bounds block for the decider's prompt, or null when every counter is zero (nothing is at risk yet).</summary>
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);

Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ public async Task<SupervisorTurnContext> RehydrateFromDecisionLogAsync(Guid supe
BrainPlaneSpendByKind = brainPlaneSpend.ByKind,
MaxCostUsd = plan.MaxCostUsd,
MaxTotalSpawns = plan.MaxTotalSpawns,
MaxResolveAttempts = plan.MaxResolveAttempts,
NoProgressDecisions = FoldNoProgressDecisions(priorDecisions),
MaxNoProgressDecisions = plan.MaxNoProgressDecisions,
ApprovalPolicy = plan.ApprovalPolicy,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ public sealed record SupervisorTurnContext
/// <summary>W-hard — the run's total-spawn cap (carried from <c>SupervisorGoalPlan.MaxTotalSpawns</c>, same plumbing as <see cref="MaxCostUsd"/>): 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.</summary>
public int? MaxTotalSpawns { get; init; }

/// <summary>P5-5 — the run's resolve-attempt cap (carried from <c>SupervisorGoalPlan.MaxResolveAttempts</c>, same plumbing as <see cref="MaxTotalSpawns"/>) 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.</summary>
public int? MaxResolveAttempts { get; init; }

private static readonly IReadOnlyDictionary<string, decimal> EmptySpendByKind = new Dictionary<string, decimal>();

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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<SupervisorPriorDecision>() })
.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);
}
}
Loading