DEV Community

Cover image for Multi-Agent Orchestration in .NET Using A2A
ohalay
ohalay

Posted on

Multi-Agent Orchestration in .NET Using A2A

Investigation of how to orchestrate an agentic system using an A2A protocol based on .NET primitives and create a PoC. Use Case: We have existing agents that support A2A, and we want to build it into a multi-agent system.

Protocol

Let's start from theory. The agent-to-agent (A2A) protocol is designed to define well-known contracts for communication between agents without humans. Every agent publishes an AgentCard /.well-known/agent-card.json. An Agent client discovers the card, then sends tasks to the agent as messages.
Our building blocks:

  • AgentCard - what the agent can do
  • Agent handler - the server-side logic
  • A2AClient - sends messages to a remote agent
graph LR
    User([User]) --> Orch[Orchestrator]
    Orch -. discover AgentCard .-> A[Assortment agent]
    Orch -. discover AgentCard .-> S[SupplyChain agent]
    Orch -- A2A SendMessage --> A
    Orch -- A2A SendMessage --> S
    A --> AT[(Catalog tools)]
    S --> ST[(Stock tools)]

PoC setup

  1. Two agents: Assortment and SupplyChain that support A2A
  2. Orchestrator: agent that routes user requests to agents and aggregates results
  3. LLM: Ollama based on Microsoft.Extensions.AI as an abstraction that support tool calling. Can be swapped for any IChatClient (Azure OpenAI, Bedrock, OpenAI, etc.).
  4. Aspire host, which connects and runs all services together and helps to understand flow using OpenTelemetry

Agent implementation

Microsoft provides an abstraction for the A2A spec for ASP.NET Core

var agentCard = new AgentCard
{
  Name = "AssortmentSpecialist",
  Description = "Handles shop inventories, product categorizations, catalogs, and store assortments.",
  Skills =
  [
    new AgentSkill
    {
       Id = "get-product",
       Name = "GetProduct",
       Description = "Look up a product's SKU, category, active status, and store coverage by name.",
    },
  ],
};

builder.Services.AddA2AAgent<DomainAgentHandler>(agentCard);

var app = builder.Build();
app.MapWellKnownAgentCard(agentCard, "");
app.MapA2A("/");
Enter fullscreen mode Exit fullscreen mode

The handler processes tasks using the LLM and the agent's own tools.

public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken ct)
{
  var responder = new MessageResponder(eventQueue, context.ContextId);

  var messages = new List<ChatMessage>
  {
    new(ChatRole.System, "You are the Assortment specialist. Use the tools to look up real data."),
    new(ChatRole.User, context.UserText ?? string.Empty),
  };

  var options = new ChatOptions { Tools = [AIFunctionFactory.Create(tools.GetProduct)] };
  var response = await chatClient.GetResponseAsync(messages, options, ct);

  await responder.ReplyAsync(response.Text, ct);
}
Enter fullscreen mode Exit fullscreen mode

Orchestrator

There are several ways to orchestrate agents

  • We craft Manual explicit route
  • Define Workflow engine
  • Let LLM route

We chose the last one because we want to have the possibility to add a new agent without any code changes. That way, we register every agent card dynamically as an orchestrator agent tool, and aggregation happens in the same LLM loop.

public async Task<string> HandleAsync(ChatThread thread, string userMessage, CancellationToken ct)
{
  var agents = await registry.GetAgents(ct);
  var tools = agents.Select(ToTool).Cast<AITool>().ToList();

  var messages = new List<ChatMessage> { new(ChatRole.System, SystemPrompt) };
  messages.Add(new ChatMessage(ChatRole.User, userMessage));

  using var client = new FunctionInvokingChatClient(chatClient)
  {
     AllowConcurrentInvocation = true,
  }.AsBuilder().Build();

  var response = await client.GetResponseAsync(
    messages,
    new ChatOptions { Tools = tools, AllowMultipleToolCalls = true },
    ct);

  return response.Text;
}
Enter fullscreen mode Exit fullscreen mode

We convert remote agents to AIFunction from AgentCard

private AIFunction ToTool(RemoteAgent agent)
{
  var dispatch = async (string request, CancellationToken ct) =>
  {
     var response = await agent.Client!.SendMessageAsync(request, Role.User, cancellationToken: ct);
     return ExtractText(response);
  };

  return AIFunctionFactory.Create(dispatch, agent.Card!.Name,
        $"Ask the {agent.Card.Name} specialist. {agent.Card.Description}");
}
Enter fullscreen mode Exit fullscreen mode

The orchestrator resolves each card with A2ACardResolver, then turns it into a tool.

A request that needs both agents makes the LLM call both agents in parallel and merge their replies into one.

sequenceDiagram
    actor User
    participant Orch as Orchestrator (LLM loop)
    participant A as Assortment agent
    participant S as SupplyChain agent

    User->>Orch: "Stores carrying the coat AND its stock?"
    par send both messages in parallel
        Orch->>A: A2A SendMessage(sub-task)
    and
        Orch->>S: A2A SendMessage(sub-task)
    end
    A-->>Orch: catalog answer
    S-->>Orch: stock answer
    Orch->>Orch: merge results
    Orch-->>User: one cohesive answer

Conclusion

  • The result is visualized with traces in the cover image
  • Protocol has a stable version, but .NET libs are still in preview
  • Communication between agents and tool calls takes time. It is better suited to long-running tasks than for immediate responses.
  • Managing chat history is also a pain point.

A2A Multi-Agent Orchestrator

A .NET 10 PoC for the A2A. Orchestrator discovers agents over HTTP, exposes each as a tool to one LLM loop, and aggregate to one response. All LLM inference runs locally through Ollama (llama3.2).

Architecture

graph TB
    Ollama[("Ollama<br/>llama3.2<br/>local LLM (external)")]

    User([User / Browser]) -->|HTTP| Orch

    subgraph Orch["Orchestrator"]
        API["Minimal API + chat UI<br/>/api/chat"]
        Svc["OrchestrationService<br/>one tool-calling LLM loop"]
        Reg["AgentRegistry<br/>(AgentCards + A2AClients)"]
        Store["ChatStore<br/>(history by threadId)"]
        API --> Svc
        Svc --> Reg
        Svc --> Store
    end

    Svc -->|LLM: tool loop + synthesis| Ollama

    subgraph Assort["AssortmentSpecialist (A2A server)"]
        AH["DomainAgentHandler"]
        AT["AssortmentTools<br/>GetProduct / GetActiveCatalog"]
        AH --> AT
    end
    subgraph Supply["SupplyChainAnalyst (A2A server)"]
        SH["DomainAgentHandler"]
        ST["SupplyChainTools<br/>GetStock / GetShipments"]
        SH --> ST
    end

    Reg -.->|discover AgentCard| Assort
    Reg -.->|discover AgentCard| Supply
    Svc -->|A2A SendMessage / tool call| Assort
    Svc -->|A2A SendMessage / tool call| Supply
    AH -->|LLM: tool-calling| Ollama
    SH -->|LLM: tool-calling| Ollama
Loading

See docs/architecture.md for diagrams and the full request flow, and AGENTS.md

Top comments (1)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

The AgentCard-as-tool conversion is the part I'd underline. It's the same shape we use for MCP servers in our multi-agent setup on a VPS — every remote capability becomes a function the orchestrator LLM can call, and adding a service doesn't touch the orchestrator's code. Once you've worked that way, hard-coded routing feels like writing switch statements over HTTP endpoints.

FunctionInvokingChatClient with AllowConcurrentInvocation is a neat fit for the parallel fan-out in your sequence diagram — merging the catalog and stock answers in a single LLM pass instead of two serialized round trips.

Two failure modes I'd love to hear how you handle:

  1. When one sub-agent times out or returns garbage mid-loop, does the FunctionInvokingChatClient surface the error to the LLM so it can retry or degrade, or does the whole GetResponseAsync throw? We ended up wrapping dispatches in retry-plus-a-failure-summary tool result, otherwise one flaky agent poisons the merged answer.

  2. Remote agent replies are model-controlled tool results, so a compromised or hallucinating downstream agent can inject instructions into the orchestrator's context. Do you sanitize or at least tag the extracted text before it goes back into the loop?

The Aspire + OpenTelemetry traces sound like the pragmatic heart of the PoC, honestly. "Why did the orchestrator call stock before catalog" is exactly the kind of question you want answered from a timeline, not from a debugger.