Back to homepage

Niels Kootstra

Agent Framework fan-out, fan-in and loops in C# with Claude

Summarize with AI

The assistants open a new chat that asks for a short summary of this post.

The idea: checks that don’t depend on each other can run at the same time, and a loop that asks a model to fix its own work needs a way out that doesn’t depend on the model. A counter in the message is that way out.

The planner in the routing post returned a valid, typed plan with salmon on all three days. The household eats fish at most once a week, but nothing checked. This post builds the checking half of the dinner workflow: the planner’s plan goes to three rule checks at once, a barrier waits for all three and combines their findings into one review, and a plan with problems goes back to the planner, for at most three rounds.

Versions: Microsoft Agent Framework 1.24.0, .NET 10.

Code: samples/agent-framework/10-workflow-parallel, a runnable version of this post with its tests.

Who this is for: C# developers who have routed a workflow on a typed decision, as in Agent Framework workflow routing in C# with Claude.

Note

The code runs on Claude Haiku 5.5, but it depends only on IChatClient: the first post shows the swap to OpenAI or Ollama.

The output limit of 4,096 tokens is sized for Haiku, which counts its reasoning as output.

The graph

problems, round < 3 otherwise input planner peanuts fish weekday-time combine result output

Two of the steps call a model: planner, and peanuts, which asks a model whether a dish hides peanuts. The fish rule and the weekday time rule are plain C#, and so are the steps that combine the checks and write the result. In the full dinner assistant this graph sits behind the routing post’s WeekPlan branch; here it starts at the planner, so the input is the request itself.

Three things are new compared with the routing post: an executor that handles two kinds of message, a fan-out with a barrier, and an edge that points backwards.

The first brings some machinery. A step with one handler, like the routing post’s Executor<TInput, TOutput>, needs nothing extra. A step that handles more than one message type, or sends a message it doesn’t return, needs a source generator package and the partial keyword: from attributes on its methods, the generator writes the code that hands each message to the handler for its type, much like a switch on the message’s type. The planner and the combine step are such steps. The ideas of the post, three checks at once and a loop with a way out, are in Three checks at once and A loop that stops after three rounds, and the code in between can be skimmed on a first read. If every check were plain C#, a for loop around HouseholdRules would do the same job; the graph pays off when several checks wait on a model at once.

Add the packages

A console app with four packages:

The fourth is a source generator. The planner and the combine step below each handle more than one message or send a message they don’t return, and for such an executor the generator writes the routing code from attributes on its methods. Microsoft.Agents.AI.Workflows doesn’t bring it along. Without it, those classes don’t compile: error CS0534: 'Planner' does not implement inherited abstract member 'Executor.ConfigureProtocol(ProtocolBuilder)'.

Messages carry the round

The steps pass records to each other, in Messages.cs:

A PlanDraft carries the household’s request, the plan and its round number, which is what ends the loop later. Each check answers with a RuleCheck, and the combine step turns three of them into one Review. DinnerPlan, Dinner and PlanText.Lines are the same as in the routing post, and so is the CompleteResult extension that fails the run when the model stops early.

An executor with two handlers

The planner gets two kinds of message: the request, for the first plan, and a Review from the combine step when a plan has to be fixed. An executor with more than one handler derives from Executor, is partial, and marks each handler with [MessageHandler]:

The generator reads the first parameter of each handler and routes messages of that type to it. Without partial, it stops the build with error MAFGENWF003: Class 'Planner' contains [MessageHandler] methods but is not declared as partial. A handler’s return value is sent along the edges, like the return value of HandleAsync in the routing post. Each handler call is one agent run without a session: the revision prompt carries the request, the last plan and the problems, so the planner needs nothing from an earlier call.

Three checks at once

AddFanOutEdge(plan, [peanuts, fish, weekdayTime]) sends every PlanDraft the planner returns to all three checks. Two of them are the household’s rules from the structured output post, split in two, in HouseholdRules.cs:

CheckWeekdayTime also keeps the structured output post’s check for minutes below 1, on any day: the [Range(1, 240)] on Minutes only describes the schema, and nothing on the way from the model’s JSON to this check enforces it.

The peanut rule is the third. The code check in the structured output post only saw the main ingredients the model listed, so peanuts in a satay sauce would get through. Recognising a sauce that usually contains peanuts is a judgement, so this check asks a model, with a typed answer:

The routing post showed the supersteps a run is made of: in each one, every executor that has a message waiting gets it, and the messages those executors send are delivered in the next. The in-process runner starts the executors of one superstep together and waits for all of them, so the three checks run at the same time and the superstep takes as long as the slowest. Here that is the peanut check, the only one that calls a model; the two code checks don’t wait for anything, so running them alongside it saves almost nothing. The shape pays off when several branches each wait for a model.

The cost is load: every branch that calls a model is a request in flight at the same time, so a wide fan-out reaches a provider’s rate limit sooner.

One verdict from three checks

AddFanInBarrierEdge([peanuts, fish, weekdayTime], combine) holds the checks’ messages until each of the three has sent at least one. It then delivers them all to combine in the next superstep and starts waiting again, which is what makes the barrier usable in a loop. The combine step gets three RuleCheck messages, one handler call each, and has to answer once:

OnMessageDeliveryFinishedAsync runs once in each superstep in which the executor receives messages, after its handlers have handled all of them. It also runs when a handler throws, and then the messages after that one are not handled. The handler collects, the hook combines and clears the list for the next round. The checks arrive in whatever order they finished, so the problems are sorted by rule to keep the review the same from run to run.

A value sent with SendMessageAsync has to be declared. Executor<TInput, TOutput> and a handler’s return type declare their own; this executor sends a Review that is no handler’s return value, so it says so with [SendsMessage(typeof(Review))]. Without the attribute, the send throws Executor 'combine' cannot send messages of type 'Review'. It is reported as a WorkflowErrorEvent only, without an ExecutorFailedEvent, because it is thrown from the hook after the handlers have finished. With the generator package referenced, a class that uses [SendsMessage] or [YieldsOutput] without any [MessageHandler] method has to be partial too, or the build stops with error MAFGENWF008. CombineChecks has a handler, so leaving out partial there gives MAFGENWF003, as with the planner.

A barrier has a failure of its own. If one check throws, it sends nothing, so the barrier never opens: the run reports the failed executor and ends without output. In the sample’s tests, a peanut check whose model stops at the output token limit leaves combine without a single call.

A loop that stops after three rounds

Edges can point backwards. The whole graph, in DinnerWorkflow.cs:

The two code checks are method groups turned into executors with BindAsExecutor, as the decline branch was in the routing post. The combine step’s Review has two edges with conditions. A review with problems goes back to the planner until the plan has had three rounds. A passing plan, or the third failing one, goes to the result. The two conditions don’t overlap and together cover every review, because conditional edges, unlike a switch, have no first match and no default. The way out of the loop depends only on a counter in the message, not on the model ever producing a plan that passes, so a planner that keeps breaking a rule costs three rounds of tokens and stops.

The result step writes the plan and the verdict:

What to do with a plan that still breaks a rule after three rounds is a decision for the household. Here it is shown with its problems, not hidden. An app that shows plans to people should treat that verdict as a block, not a warning: a plan that failed the peanut rule must not look approved. Agent Framework workflow checkpoints in C# with Claude puts a person in the workflow: it stops at a request, saves itself to disk and goes on when the answer comes.

Run it

Program.cs creates the chat client and the two agents, and runs the workflow for one request. The planner doesn’t know the household’s rules, as in the structured output post: the checks do.

The event loop is the routing post’s, with one addition: when an executor receives a Review, it prints the review’s problems, so a plan that goes back to the planner shows why.

A run:

In every round the three checks ran in one superstep, and the barrier delivered their three messages to combine together in the next. Read combine combine combine as one superstep in which combine handled three messages; it sent one review. The first plan had salmon on all three days, the fish check sent it back, and the second plan kept every rule. That took four model calls, a plan and a peanut check per round. The sample ran it three times, and all three went this way: two rounds, the fish rule the only problem, nothing flagged by the peanut check. A plan that breaks a rule in every round would take three rounds and six calls, and end with “Still wrong after round 3:” and the remaining problems.

One workflow, one run at a time

A Workflow can be run by one runner at a time. Running the same Workflow again works once the StreamingRun of the previous run is disposed; starting a second run while the first is still open throws Cannot use a Workflow that is already owned by another runner or parent workflow., as the first workflow post showed.

InProcessExecution.Concurrent is an execution environment that allows several runs of one workflow at once, but only if every executor can be shared between runs. An agent placed in the graph directly can. An executor instance, like new CombineChecks(), can only if it was created with declareCrossRunShareable: true, and the same goes for BindAsExecutor with threadsafe: true. This workflow has neither, and for good reason: CombineChecks keeps a list between its handler and its hook, and two runs would fill the same list. Concurrent refuses it with Workflow must only consist of cross-run share-capable or factory-created executors., followed by the IDs of the executors that can’t be shared. To serve several households at once with this graph, build one Workflow per run.

Try it

  • Set MaxRounds to 1 and run again. The plan now goes to the result after its first round, and the fish problem is listed under “Still wrong” instead of going back to the planner.
  • Ask for “Dinners for Monday to Friday. We love satay.” and see whether the peanut check catches what the code check in the structured output post missed.
  • Remove [SendsMessage(typeof(Review))] from CombineChecks and run again. The run ends with a WorkflowErrorEvent after the first round of checks.