> ## Content Index
> Fetch the complete content index at: https://devinside.cloud/llms.txt
> Use this file to discover other available public pages before exploring further.

# When an Agent Coordinator Has a Real Choice
- URL: https://devinside.cloud/decision-apis-09-bounded-agent-coordination/
- Published: 2026-11-13T11:04:18.000Z
- Updated: 2026-11-13T22:06:18.000Z
- Description: 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.
- Author: Marcin Borecki
- Tags: AI Engineering, .NET & C#, Series: Decision APIs, #series-decision-apis, #dapi09

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:

```csharp
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](https://devinside.cloud/decision-apis-08-rag-evidence-sufficiency/), 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

```csharp
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:

```text
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

| Run                             | Logical calls | Cost units | Accepted findings |
| ------------------------------- | ------------- | ---------- | ----------------- |
| One joint specialist (basic)    | 1             | 0          | 2                 |
| Fixed order (basic)             | 2             | 3          | 2                 |
| Fixed order (mixed)             | 3             | 4          | 3                 |
| Selector picks account (mixed)  | 4             | 5          | 3                 |
| Selector returns done (mixed)   | 1             | 1          | 0                 |
| Specialist claims a fix (basic) | 4             | 8          | 0                 |

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](https://devinside.cloud/evidence-driven-replanning-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

```csharp
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

```csharp
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:

| Style                              | Who chooses the next step                                                  | Where your rules go                                                                                      |
| ---------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Fixed workflow (edges)             | The graph                                                                  | The edges themselves; conditions on edges                                                                |
| Central coordinator (this article) | Application code, with an optional checked selector                        | Legal-role computation, admission, acceptance                                                            |
| Group chat                         | A GroupChatManager; RoundRobinGroupChatManager has a MaximumIterationCount | Override SelectNextAgentAsync to return only legal roles, and ShouldTerminateAsync to test your criteria |
| Handoff                            | The current agent transfers control                                        | Which handoffs exist at all                                                                              |
| Agent as tool                      | A calling agent, via AsAIFunction                                          | The 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](https://devinside.cloud/an-agent-cost-ledger-in-net-attempts-quality-and-successful-tasks/).
- **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?

| Situation                                                        | Use                                      | Evidence to collect                                                      |
| ---------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------ |
| Dependencies fix the order                                       | Fixed workflow, zero selector calls      | None beyond correctness checks                                           |
| Several legal orders, stable priority rule                       | Deterministic ordering rule              | That the rule matches policy                                             |
| Several legal orders, priority depends on uncertain case details | Checked model selector at the fork only  | Outcome improvement over the rule on the same cases, extra calls counted |
| One model can produce all findings                               | Joint specialist with per-finding checks | Per-finding acceptance rate against separate specialists                 |
| Finite licensed answers                                          | Template renderer, no model              | Coverage 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](https://devinside.cloud/decision-apis-08-rag-evidence-sufficiency/). Next: [Part 10, human approval after a restart](https://devinside.cloud/decision-apis-10-human-review-resume-idempotency/).

## Further reading

- [Group chat orchestration](https://learn.microsoft.com/en-us/agent-framework/workflows/orchestrations/group-chat?ref=devinside.cloud) (Microsoft Learn): `GroupChatManager`, `RoundRobinGroupChatManager`, `MaximumIterationCount` and custom termination.
- [Magentic orchestration](https://learn.microsoft.com/en-us/agent-framework/workflows/orchestrations/magentic?ref=devinside.cloud) (Microsoft Learn): a planner-led alternative with round, stall and reset limits.
- [Structured output for agents](https://learn.microsoft.com/en-us/agent-framework/agents/structured-output?ref=devinside.cloud) (Microsoft Learn): typed agent responses such as `RunAsync<T>`.
- [microsoft/agent-framework](https://github.com/microsoft/agent-framework?ref=devinside.cloud): source of `ChatClientAgent`, the workflow builders and the orchestration managers.