When an Agent Coordinator Has a Real Choice

Drop selector calls where the dependency graph already decides, keep a checked selector only at a real fork, and charge every attempt so a fluent loop that makes no progress still stops.

Two arborists examine a pear tree through different investigations, one at its roots and one at its leaves, before opening a pruning kit.

The support workflow has two specialists. The technical specialist produces the diagnostic finding. The service specialist uses that finding to explain the next step, so it may not act first. Once the diagnostic finding is accepted, the technical specialist has nothing left to do. Which specialist should act next?

At every point in that workflow, the application already knows the answer. A design that asks a model to choose anyway adds a call that can only reproduce the known edge, pick a role that is not allowed, or declare the task finished when it is not. This part of the series removes that artificial choice, then builds a case in which two investigations really are both permitted, and counts what the choice costs.

  • If the dependency graph allows one next role, call it. A model selector belongs only where the legal list has more than one entry.
  • The application owns completion criteria, evidence, legal transitions and budgets. A model may propose a role inside those limits; its answer is checked against the legal list after the call.
  • Count logical model calls, attempts and accepted findings separately. Charge every attempt before dispatch, so a fluent loop that makes no progress still runs out of budget.
  • Accept a finding only if it satisfies its criterion with a licensed citation from the evidence packet. Valid JSON is not progress.

Count every logical call, not just the turns

Consider a coordinator design that is common in agent demos. Before coordinating, it asks a model whether the case should be drafted at all. Then, before each specialist turn, it asks a selector model which specialist should speak. In the basic case that is a preflight, a selector that picks technical, the technical specialist, a selector that picks service, and the service specialist.

Two accepted findings cost five model calls, and both selector calls had exactly one legal answer. The words "two-turn conversation" hide three of the five.

Before and after

Two findings: five model calls with a selector per turn, two with the dependency graph

Selector before every turn preflight model call selector legal: technical technical diagnostics selector legal: service service next step 5 Dependency graph coverage gate Part 8, 0 calls graph edge 0 calls technical diagnostics graph edge 0 calls service next step 2 Both designs must produce the same two accepted findings.
  1. The basic case needs two accepted findings, diagnostics and next-step. The upper design calls a model before and between the two specialist turns; the lower one follows the dependency graph.
  2. Both selector calls face a legal list with a single entry. In the dependency graph each becomes an edge that costs nothing and cannot pick the wrong role.
  3. The model preflight asked whether the case may be drafted. With the finite rules of Part 8, coverage answers that question deterministically before any agent runs.
  4. The result: five logical model calls against two, for identical findings. The three removed calls added failure modes and no decision.

Call counts follow from the structure of each design; the two-call path is the fixed-order run printed later in this article.

Vocabulary matters for the rest of the article. A specialist attempt is one invocation of a specialist. An accepted finding is a result the application admitted. A logical model call is any call to a model, selectors included. A transport retry would add a physical request without adding a logical call. The examples here perform no transport retries, so the two numbers coincide; neither is a billing figure.

Establish the simpler baselines first

The legal next roles come from the remaining criteria, not from a prompt:

var legal = new List<string>();
if (remaining.Contains("diagnostics")) legal.Add("technical");
if (remaining.Contains("account-status")) legal.Add("account");
if (legal.Count == 0 && remaining.Contains("next-step")) legal.Add("service");

In the basic case, account-status is not required, so the list is technical at first and service after diagnostics is accepted. That is an ordinary dependency graph, even though its nodes call agents. There are two cheaper designs to compare against before anything clever is added:

  • One joint specialist. A single ChatClientAgent returns a typed set with both findings. The application checks each finding, rejects duplicates and orders the text. One attempt, one logical call, two accepted findings.
  • Fixed order. Technical, validate, service, validate. Two attempts, two calls.

Both produce the same two licensed sentences in the runs below, because the specialist outputs are authored fixtures that hold the answer constant. That isolates orchestration overhead. It does not show that one live model handles both jobs as reliably as two focused prompts; claiming that needs the same real cases, evidence and independently judged outcomes for each design. There is an even simpler baseline for this restricted task: with the finite clause rules from Part 8, a template renderer can join the licensed clauses without any model call. Agents are kept here to show where richer implementations need their checks, not because the task needs them.

Introduce a choice that is real

Now the same customer also asks about a duplicate charge. The evidence packet includes BILL-42, whose curated statement says that the duplicate-charge investigation is open and no credit has been authorized. The required criteria become diagnostics, account-status and next-step.

Technical and account investigations may both start immediately; neither depends on the other. The service response must wait for both, because it has to state the technical constraint without implying that a credit was granted. At the first step the legal list is [technical, account]. After either finding is accepted, the other investigation is the only legal role. After both, service is. The selector has exactly one opportunity to add value, not three.

State machine

The fork is the only real choice. The join is a rule, not a question.

evidence packet, Part 8 selector 2 legal roles technical diagnostics account account-status service waits for both graph edge, 0 calls calls 0 units 0 unmet diagnostics, account-status, next-step
  1. Three criteria are unmet. Technical and account are both legal; service must wait for both findings. Costs per call: selector 1, technical 2, account 1, service 1 unit.
  2. Because the legal list has two entries, the selector runs. It is charged 1 unit before the call, and its answer, account, is checked against the legal list.
  3. The account specialist returns the BILL-42 finding. It is licensed, so account-status is removed from the unmet set. Two calls, two units.
  4. Now technical is the only legal role. The coordinator follows the edge without asking anyone: the trace gets an event, the call count stays at two.
  5. The technical specialist's finding cites GW-241 and is accepted. Three calls and four units; only next-step remains.
  6. Service runs last and its HOLD-7 finding is accepted. Complete with four logical calls and five units, against three calls and four units in fixed order.

Trace and totals from the executed run "mixed, selector picks account". The selector's choice is an authored fixture, not a measured prioritization.

Is account-first better? In this run it reaches the same final text with one more call. A production reason to put account first could be a time-sensitive dispute; a reason to put technical first could be an outage. If the priority rule is stable and explicit, write it as code. A model earns the selector seat only when interpreting uncertain case details changes the order in a way that measurably improves the outcome, and that benefit survives evaluation against the cheaper rule.

Make the coordinator's state and budget inspectable

The coordinator consumes the evidence packet from Part 8 and keeps an unmet-criteria set, accepted findings, a trace, the attempt count, the call count and the cost units spent. The policy sets maximum attempts (4), maximum calls (6) and maximum units (9), plus the cost of each kind of call. Units are synthetic: they make admission testable before dispatch and are not token prices. The effective policy, numbers included, later becomes part of the identity a human approves in Part 10.

Code walkthrough

Legal roles from the graph, a checked selector, and a budget charged before every call

while (remaining.Count > 0)
{
    // Legal next roles come from the dependency graph, not from a model.
    var legal = new List<string>();
    if (remaining.Contains("diagnostics")) legal.Add("technical");
    if (remaining.Contains("account-status")) legal.Add("account");
    if (legal.Count == 0 && remaining.Contains("next-step")) legal.Add("service");

    string role = legal[0];
    if (legal.Count > 1 && selectorChoice is not null)
    {
        if (!CanSpend("selector")) return Done("CallOrCostBudgetExhausted", accepted);
        Spend("selector");
        role = (await Agent(new Selection(selectorChoice), "Choose one legal role.")
            .RunAsync<Selection>(JsonSerializer.Serialize(new { evidence.SnapshotId, legal }),
                cancellationToken: ct)).Result.Role;
        trace.Add($"selector:{role}");
        if (!legal.Contains(role, StringComparer.Ordinal)) return Done("InvalidSelection", accepted);
    }
    else trace.Add($"edge:{role}");

    if (attempts >= policy.MaxAttempts) return Done("AttemptBudgetExhausted", accepted);
    if (!CanSpend(role)) return Done("CallOrCostBudgetExhausted", accepted);
    attempts++;
    Spend(role);                       // charged before the call; never refunded

    string criterion = RoleFor.First(kv => kv.Value == role).Key;
    Finding finding = (await Agent(specialist(role), $"You are the {role} specialist.")
        .RunAsync<Finding>(JsonSerializer.Serialize(new { evidence.SnapshotId, criterion }),
            cancellationToken: ct)).Result;

    if (!evidence.Licensed.Values.Any(c => c.DocId == finding.DocId && c.Version == finding.Version))
    { trace.Add($"{role}:InvalidFinding"); return Done("InvalidFinding", accepted); }

    if (finding.Criterion != criterion || !Supports(evidence, finding))
    { trace.Add($"{role}:rejected"); continue; }

    accepted.Add(finding);
    remaining.Remove(criterion);
    trace.Add($"{role}:accepted");
}
return Done("Complete", accepted);
  1. The legal list is computed from the unmet criteria on every iteration. It is the only source of permission in the loop.
  2. The selector runs only when there are at least two legal roles. Its typed answer is checked after the call; a role outside the list, such as done, stops the run as InvalidSelection.
  3. Before any specialist call, the coordinator checks the attempt limit and the remaining calls and units, then charges the attempt. A rejected result is not refunded.
  4. Agent wraps a ChatClientAgent around an IChatClient; RunAsync<Finding> asks for structured output. The request carries the snapshot identity and the criterion, never the ineligible ranking.
  5. A citation to a source outside the evidence packet is a broken contract, not a weak answer. The run stops with InvalidFinding.
  6. A finding counts only if it is for the requested criterion and its citation licenses its sentence. Only then is the criterion removed from the unmet set.

In this program, the IChatClient behind each agent is a fixture that returns authored JSON and increments the call counter inside GetResponseAsync. The agents, structured-output parsing and checks are real Microsoft Agent Framework code (packages Microsoft.Agents.AI and Microsoft.Agents.AI.Workflows 1.28.0 on .NET 10); the model quality is not measured. Deterministic edges appear in the trace and add zero calls. That makes a common accounting error visible: a long trace is not necessarily expensive, and a short "conversation" can hide several selector calls.

Stop a valid-looking loop that makes no progress

The hard failure is not an illegal role. It is a legal specialist returning fluent, well-formed, unsupported results, again and again. If the budget counts only accepted turns, that loop never consumes anything.

In the no-progress run, the technical specialist returns "The issue is fixed." with a citation to GW-241. The passage supports a possible symptom, not a completed repair, so the finding is rejected and diagnostics stays unmet. Technical remains the only legal role. Four attempts consume eight units and four calls; nothing is accepted; the fifth attempt is refused with AttemptBudgetExhausted. Every scenario below was run with the loop above:

basic, fixed order                 Complete                   attempts=2 calls=2 units=3 accepted=2
mixed, fixed technical-first       Complete                   attempts=3 calls=3 units=4 accepted=3
mixed, selector picks account      Complete                   attempts=3 calls=4 units=5 accepted=3
mixed, selector returns done       InvalidSelection           attempts=0 calls=1 units=1 accepted=0
basic, specialist claims a fix     AttemptBudgetExhausted     attempts=4 calls=4 units=8 accepted=0
basic, invented citation           InvalidFinding             attempts=1 calls=1 units=2 accepted=0
basic, allowance of 2 units        CallOrCostBudgetExhausted  attempts=1 calls=1 units=2 accepted=1
basic, one joint specialist        Complete                   attempts=1 calls=1 accepted=2
Measured

The most expensive run accepted nothing

RunLogical callsCost unitsAccepted findings
One joint specialist (basic)102
Fixed order (basic)232
Fixed order (mixed)343
Selector picks account (mixed)453
Selector returns done (mixed)110
Specialist claims a fix (basic)480
  1. Calls per run. The joint specialist needs one; the selector route needs one more than fixed order for the same three findings.
  2. Units charged before each call. The joint baseline ran outside the budgeted coordinator, so it shows zero units.
  3. What the application admitted. Four of the six runs complete; two stop with typed outcomes instead of a fabricated answer.
  4. The no-progress loop spent 8 of 9 units, the most of any run, and accepted nothing. Counting accepted turns would have recorded zero cost.
  5. The selector route costs one call and one unit more than fixed order. That is the price to justify with a measured improvement.

Counts printed by the coordinator program on .NET 10 with fixture model clients. They are call and budget counts, not latency or token measurements.

Each stopping outcome names a different repair. InvalidSelection points at the selector prompt or model. InvalidFinding means a specialist cited something outside the evidence it was given, which deserves an alert. AttemptBudgetExhausted with an unmet criterion means the evidence or the task contract cannot produce that finding. CallOrCostBudgetExhausted means the policy is too small for the case. None of them becomes "complete" because a model produced valid JSON, and the composed workflow emits a typed halt instead of preparing a review proposal. The broader pattern of a loop that must be able to stop is covered in a bounded .NET agent state machine.

A finding needs more than the case ID

A tempting structural check is "every finding must cite the case ID T-1042". It proves the specialist was talking about the right case and nothing about whether it used a supporting passage. Here a finding cites a document ID, version and span from the evidence packet, plus the sentence it claims that span supports. For diagnostics it must use the known-issue licence, for the next step audit-constraint, for account status the BILL-42 statement. A valid citation to the wrong fact cannot satisfy an unrelated criterion.

Structural acceptance

if (finding.CaseId == "T-1042" && finding.Citations.Count > 0)
{
    accepted.Add(finding);
    remaining.Remove(finding.Criterion);   // criterion chosen by the model
}

"The issue is fixed." with a GW-241 citation is accepted, the model decides which criterion it satisfied, and the loop ends early.

Licensed acceptance

public static bool Supports(EvidencePacket e, Finding f) =>
    e.Licensed.TryGetValue(f.Fact, out var c)
    && c.DocId == f.DocId && c.Version == f.Version
    && c.SpanId == f.SpanId && c.Clause == f.Text;

if (finding.Criterion != criterion || !Supports(evidence, finding)) continue;

The coordinator fixes the criterion, and the citation must license the exact sentence. The no-progress loop is rejected four times and stopped.

This is stronger than accepting any structured finding and still limited to the finite clause contract of Part 8. Free-form findings would need a support check of their own, evaluated for false accepts and false rejects. And what the specialist receives matters as much as what it returns: the request carries the ticket, the snapshot identity, the selected documents, the criterion and the accepted findings, never the ineligible ranking.

Keep orchestration styles separate

Microsoft Agent Framework ships several orchestration builders on AgentWorkflowBuilder: sequential, concurrent, handoff, group chat and Magentic. They can share agent implementations without being interchangeable:

StyleWho chooses the next stepWhere your rules go
Fixed workflow (edges)The graphThe edges themselves; conditions on edges
Central coordinator (this article)Application code, with an optional checked selectorLegal-role computation, admission, acceptance
Group chatA GroupChatManager; RoundRobinGroupChatManager has a MaximumIterationCountOverride SelectNextAgentAsync to return only legal roles, and ShouldTerminateAsync to test your criteria
HandoffThe current agent transfers controlWhich handoffs exist at all
Agent as toolA calling agent, via AsAIFunctionThe tool's input contract and the caller's limits

The group chat documentation's custom manager ends the conversation when the reviewer's last message contains the word "approve". That is reasonable for marketing slogans and wrong for this case, where completion means a set of licensed findings, not a phrase. If you use a group chat, put the same criteria check from the loop above into ShouldTerminateAsync, and remember that all participants see the shared conversation history, which is another reason to keep ineligible text out of it. The code in this article uses a central loop with explicit specialist calls; it does not use the group chat builder.

Pitfalls

An allowance of "four specialist turns" that increments on accepted findings never runs out when nothing is accepted. Count attempts and charge before the call. The no-progress run is the test that proves it.

  • Asking the model whether the task is done. Completion is the unmet set becoming empty, checked by code. A selector returning done is an invalid selection.
  • Selector on a single-entry list. It adds a call and a failure mode. Skip it when the legal list has one role.
  • Letting a finding choose its criterion. The coordinator asks for a criterion and checks against that one.
  • Counting turns instead of calls. Report logical calls, attempts and accepted findings side by side, as in the cost ledger approach of an agent cost ledger in .NET.
  • Refunding rejected attempts. The work happened and so did its cost. A refund makes the budget a suggestion.

When does a coordinator need a model?

SituationUseEvidence to collect
Dependencies fix the orderFixed workflow, zero selector callsNone beyond correctness checks
Several legal orders, stable priority ruleDeterministic ordering ruleThat the rule matches policy
Several legal orders, priority depends on uncertain case detailsChecked model selector at the fork onlyOutcome improvement over the rule on the same cases, extra calls counted
One model can produce all findingsJoint specialist with per-finding checksPer-finding acceptance rate against separate specialists
Finite licensed answersTemplate renderer, no modelCoverage from Part 8

Checklist

  • Legal next roles are computed by code from unmet criteria on every iteration.
  • A selector runs only when the legal list has two or more roles, and its answer is validated after the call.
  • Attempts, logical calls and cost units are charged before dispatch and never refunded.
  • A finding is accepted only for the requested criterion and only with a licensed citation from the evidence packet.
  • Every stop is a typed outcome that names the repair; none produces a review proposal.
  • Specialist requests contain selected evidence only.
  • Reports show calls, attempts and accepted findings per run, including runs that accepted nothing.

Series: Decision APIs in .NET, part 9 of 10. Previous: Part 8, RAG that keeps its evidence. Next: Part 10, human approval after a restart.

Further reading

Written by

Marcin Borecki

Software architect and entrepreneur, Microsoft Regional Director since 2014, with CloudExpert since 2011. I help teams design, measure and ship .NET, Azure, data and AI systems that hold up in production.

Facing this problem in your own system?

I run focused investigations, architecture reviews and hands-on workshops for engineering teams.

See how I can help