DEV Community

Cover image for Multi-agent orchestration on Azure AI Foundry with .NET: AsAIFunction, A2A, and when one agent is enough
Morten Christensen
Morten Christensen

Posted on

Multi-agent orchestration on Azure AI Foundry with .NET: AsAIFunction, A2A, and when one agent is enough

When you build a Foundry agent that needs more than one capability, the natural impulse is to reach for multi-agent orchestration. Spin up a ResearchAgent, a WriterAgent, an OrchestratorAgent. Problem solved, right? In my experience, that's not always the case. Most scenarios that feel like they need multiple agents can be handled cheaper and with less operational surface area by a single agent with well-chosen tools.

This post covers three things: a concrete heuristic for deciding when to split agents, the two in-process orchestration patterns available in the Microsoft Agent Framework .NET SDK, and the Foundry Workflows retirement coming December 1, 2026. If your team built orchestration in the visual portal designer, I'll show you what the migration path actually looks like in code.

I'm showing my preferred approach here. If you structure this differently, or if the A2A story has changed since I wrote this, I'd be curious to hear more about it.

When one agent is actually enough

Four reasons I'll reach for multiple agents instead of one agent with more tools:

  1. The specialists need different models. A ReasoningAgent that plans and decomposes a task benefits from a slower, more capable model; a SummaryAgent that just reformats output can use something cheaper and faster. Mixing those workloads in a single agent means paying the heavier model's rate for work that doesn't need it.

  2. The specialists belong to different teams and need independent versioning and deployment. If the ResearchAgent is owned by one team and the WriterAgent by another, coupling them into a single process creates coordination overhead every time either team ships. If everything is handled by a single team this would be less of an issue, but I would still emphasize thinking about 'Separation of Concern'.

  3. One specialist has a traffic pattern that warrants independent scaling: bursty load that would starve the others if they shared capacity.

  4. The specialist lives in a different subscription, project, or organisation and can only be reached over a network.

If none of those conditions hold, stay with one agent. A single gpt-4.1-mini deployment with three function tools will be cheaper and far easier to trace than three agents calling each other. The pattern I'd call orchestration by default, adding an OrchestratorAgent before you have a specific reason for it, adds a model call at every hop and makes traces meaningfully harder to read.

In-process composition with AsAIFunction()

When you do have a genuine reason to split, the simplest path in the Agent Framework .NET SDK is AsAIFunction(). It exposes an AIAgent as an AIFunction, so the outer agent can call the inner agent as a tool, entirely within the same process. No HTTP round-trip, no Foundry deployment for the inner agent.

This is not the same as the Foundry Responses API agent-reference pattern. With AsAIFunction(), the inner agent is instantiated in your C# process with its own model connection. The outer agent's decision to call it happens through the normal tool-calling mechanism: the LLM emits a tool call, the framework resolves it to the inner AIAgent.RunAsync(), and the result comes back as a tool response. Session state lives in memory.

Here's the minimal version. A WeatherAgent that answers weather questions, exposed as a callable tool to a MainAgent:

// example-01: WeatherAgent as a callable tool of MainAgent via AsAIFunction()

using System.ComponentModel;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

// GetWeather is a static local function. [Description] attributes on the method
// and its parameter let AIFunctionFactory.Create() generate a JSON function schema
// that the model can call.
[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location)
    => $"The weather in {location} is cloudy with a high of 15°C.";

string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
    ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4.1-mini";

// DefaultAzureCredential — for local dev, use `az login` first.
// In production, prefer ManagedIdentityCredential to avoid fallback probing.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());

// Wrap the static C# method as an AITool with a function schema.
AITool weatherTool = AIFunctionFactory.Create(GetWeather);

// Create the WeatherAgent in-process. Nothing is deployed to Foundry.
// AsAIAgent() is an extension method from Microsoft.Agents.AI.Foundry.
AIAgent weatherAgent = aiProjectClient.AsAIAgent(
    deploymentName,
    instructions: "You answer questions about the weather.",
    name: "WeatherAgent",
    tools: [weatherTool]);

// Expose the WeatherAgent as a named AIFunction the MainAgent can invoke as a tool.
// AsAIFunction() is the in-process composition primitive — no HTTP round-trip.
AIAgent mainAgent = aiProjectClient.AsAIAgent(
    deploymentName,
    instructions: "You are a helpful assistant who responds in Portuguese.",
    name: "MainAgent",
    tools: [weatherAgent.AsAIFunction()]);

// CreateSessionAsync initialises in-memory conversation state.
// Pass the same session into every RunAsync() call to retain context across turns.
AgentSession session = await mainAgent.CreateSessionAsync();
Console.WriteLine(await mainAgent.RunAsync("What is the weather like in Amsterdam?", session));
Enter fullscreen mode Exit fullscreen mode

A few things to pay attention to in that code. AIFunctionFactory.Create(GetWeather) wraps the static C# method as an AITool with a function schema the framework can serialise and send to the model. aiProjectClient.AsAIAgent(deploymentName, instructions, name, tools) creates the agent in-process, nothing is deployed to Foundry. weatherAgent.AsAIFunction() packages the whole WeatherAgent as a named tool the MainAgent can invoke. And mainAgent.CreateSessionAsync() sets up the conversation state; pass the same session into every RunAsync() call if you want the agent to remember earlier turns.

The call flow: the user asks MainAgent about Amsterdam weather, MainAgent decides to call the WeatherAgent tool, WeatherAgent invokes its own GetWeather function, and the result climbs back up the stack as a tool response. All of this is in memory. From the outer agent's perspective it looks like any other tool call.

Oh, and a small twist: the main agent will respond in Portuguese. There is no point in having a main agent if it's just getting the weather from another agent and doing nothing else, so here the main agent also has a specialized instruction.

Install Microsoft.Agents.AI.Foundry (1.5.0 at time of writing) and it brings in Microsoft.Agents.AI, Azure.AI.Projects, and the other dependencies transitively. Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in your environment, authenticate with az login for local development, and that's the full setup.

Three agents, one pipeline

Once two agents compose cleanly, adding a third follows the same pattern. The scenario that actually justifies three agents: a ResearchAgent that retrieves current information using a web search tool, a WriterAgent that takes research output and produces structured text, and an OrchestratorAgent that coordinates them in sequence.

// example-02: Three-agent sequential pipeline via AsAIFunction()

using System.ComponentModel;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

// --- Stub tools ---

// Real implementation: call Bing Grounding tool or a web search API.
// Stub returns hardcoded research so the pipeline runs without a search subscription.
[Description("Research a topic and return a summary with key facts.")]
static string GetCurrentResearch(
    [Description("The topic to research.")] string topic)
{
    Console.WriteLine($"[ResearchAgent tool] GetCurrentResearch(\"{topic}\")");
    return $$$"""
    {
      "topic": "{{{topic}}}",
      "summary": "Recent studies show significant advances in {{{topic}}}.",
      "keyFacts": [
        "Fact 1: adoption grew 40% in the past year",
        "Fact 2: leading practitioners cite cost reduction as the primary driver",
        "Fact 3: open standards are gaining momentum over proprietary approaches"
      ]
    }
    """;
}

// Real implementation: write to a file or blob storage.
// Stub prints to Console so the delegation pattern is observable.
[Description("Write a short article draft and save it.")]
static void WriteArticle(
    [Description("The article content to write.")] string content,
    [Description("The filename to save the article to.")] string filename)
{
    Console.WriteLine($"[WriterAgent tool] WriteArticle(\"{filename}\")");
    Console.WriteLine("--- Draft content ---");
    Console.WriteLine(content);
    Console.WriteLine("--- End draft ---");
}

string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
    ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4.1-mini";

AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());

// ResearchAgent: uses a web-search stub tool.
AIAgent researchAgent = aiProjectClient.AsAIAgent(
    deploymentName,
    instructions: "You are a research specialist. When asked to research a topic, call GetCurrentResearch and return the results.",
    name: "ResearchAgent",
    tools: [AIFunctionFactory.Create(GetCurrentResearch)]);

// WriterAgent: uses a file-writing stub tool.
AIAgent writerAgent = aiProjectClient.AsAIAgent(
    deploymentName,
    instructions: "You are a technical writer. When given research results, call WriteArticle to draft a short article.",
    name: "WriterAgent",
    tools: [AIFunctionFactory.Create(WriteArticle)]);

// OrchestratorAgent: treats both specialists as named tools via AsAIFunction().
// The system prompt makes the delegation sequence deterministic.
AIAgent orchestratorAgent = aiProjectClient.AsAIAgent(
    deploymentName,
    instructions: """
        You coordinate a research and writing pipeline.
        Step 1: Call ResearchAgent with the user's topic to gather research.
        Step 2: Call WriterAgent with the research results to produce a draft.
        After both steps complete, summarise what was done.
        """,
    name: "OrchestratorAgent",
    tools: [researchAgent.AsAIFunction(), writerAgent.AsAIFunction()]);

// A single session is kept alive across both specialist calls.
// The orchestrator accumulates intermediate results in this session.
AgentSession orchestratorSession = await orchestratorAgent.CreateSessionAsync();

string topic = "multi-agent orchestration patterns";
Console.WriteLine($"[OrchestratorAgent] Starting pipeline for topic: \"{topic}\"");
Console.WriteLine();

var result = await orchestratorAgent.RunAsync(
    $"Research and write a short article about: {topic}",
    orchestratorSession);

Console.WriteLine();
Console.WriteLine("[OrchestratorAgent] Pipeline complete.");
Console.WriteLine(result.Text);
Enter fullscreen mode Exit fullscreen mode

There are a couple of things about this pattern that are easy to get wrong.

The orchestrator does not automatically receive the inner agents' intermediate tool calls. When it calls ResearchAgent, it gets back the text result. It never sees the individual web search calls the research agent made internally. That's usually what you want: specialists hide their implementation details behind a clean result boundary.

Context does not flow between specialists on its own, either. If WriterAgent needs the research results, the orchestrator has to include them explicitly in the message it sends to the writer. You own that assembly step. The framework does not thread context through for you.

And the cost. Each specialist invocation is a separate model call. If the orchestrator loops over five topics and calls both specialists per topic, you're looking at ten model calls just for the specialists, plus the orchestrator's own calls on top of that. Multi-agent is not free. Run the numbers against a single-agent approach before committing.

A2A (Agent 2 Agent): when the specialist lives somewhere else

AsAIFunction() only works when both agents run in the same process. The moment a specialist lives in a different Foundry project, a different subscription, or a different organisation, you need A2A.

The A2A tool implements the open A2A protocol, so it works with any A2A-compatible endpoint, not just agents built on Foundry. You configure a project connection in the Foundry portal (key-based auth, managed OAuth, managed identity, or agent identity are all supported), and your agent references that connection by name at runtime. The code example has a readme with a step by step guide for creating the connection via Foundry.

// example-03: A2A cross-boundary agent call using Azure.AI.Projects SDK

using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.AI.Extensions.OpenAI;
using Azure.Identity;
using OpenAI.Responses;

string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
    ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
string modelDeployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4.1-mini";
string a2aConnectionName = Environment.GetEnvironmentVariable("FOUNDRY_A2A_CONNECTION_NAME")
    ?? throw new InvalidOperationException("FOUNDRY_A2A_CONNECTION_NAME is not set.");
// Only needed when the connection type is not RemoteTool_Preview (i.e., raw A2A endpoints
// stored as a non-RemoteTool connection type won't carry the URI automatically).
string? a2aBaseUri = Environment.GetEnvironmentVariable("FOUNDRY_A2A_BASE_URI");

AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());

// Resolve the connection name to a full AIProjectConnection.
// The .Id property is the ARM resource path used by A2APreviewTool.
AIProjectConnection a2aConnection = projectClient.Connections.GetConnection(a2aConnectionName);

// Configure the A2A tool with the connection ID.
A2APreviewTool a2aTool = new()
{
    ProjectConnectionId = a2aConnection.Id
};

// The docs describe checking for "RemoteA2A" connection type to decide whether to set BaseUri.
// In Azure.AI.Projects 2.0.1, the corresponding enum value is ConnectionType.RemoteTool
// (string value "RemoteTool_Preview"). A RemoteTool connection carries the A2A endpoint URI
// inside the connection record; other connection types require BaseUri to be set explicitly.
if (a2aConnection.Type != ConnectionType.RemoteTool)
{
    if (string.IsNullOrWhiteSpace(a2aBaseUri))
    {
        throw new InvalidOperationException(
            $"Connection '{a2aConnection.Name}' has type '{a2aConnection.Type}' (not RemoteTool). " +
            "Set FOUNDRY_A2A_BASE_URI to the A2A endpoint base URI.");
    }
    a2aTool.BaseUri = new Uri(a2aBaseUri);
}

// Build the agent definition with the A2A tool.
DeclarativeAgentDefinition agentDefinition = new(model: modelDeployment)
{
    Instructions = "You are a helpful assistant.",
    Tools = { a2aTool }
};

// Create a server-side Prompt Agent version.
// This deploys the agent definition to Foundry; the returned ProjectsAgentVersion
// carries .Name and .Version for subsequent calls.
ProjectsAgentVersion agentVersion = projectClient.AgentAdministrationClient
    .CreateAgentVersion(
        agentName: "example-03-a2a-agent",
        options: new ProjectsAgentVersionCreationOptions(agentDefinition));

Console.WriteLine($"Agent created: {agentVersion.Name} (version {agentVersion.Version})");

try
{
    // Get a ProjectResponsesClient scoped to this agent.
    // GetProjectResponsesClientForAgent takes an AgentReference (name + version),
    // not just a string — the overload in the Microsoft docs that accepts a bare string
    // does not exist in Azure.AI.Extensions.OpenAI 2.0.0.
    AgentReference agentRef = new(agentVersion.Name, agentVersion.Version);
    ProjectResponsesClient responseClient = projectClient.ProjectOpenAIClient
        .GetProjectResponsesClientForAgent(agentRef);

    // Send the message, forcing the agent to use the A2A tool via CreateRequiredChoice().
    CreateResponseOptions responseOptions = new()
    {
        ToolChoice = ResponseToolChoice.CreateRequiredChoice(),
        InputItems = { ResponseItem.CreateUserMessageItem("What can the secondary agent do?") }
    };

    ResponseResult response = await responseClient.CreateResponseAsync(responseOptions);

    if (response.Status != ResponseStatus.Completed)
    {
        throw new InvalidOperationException($"Response did not complete. Status: {response.Status}");
    }

    Console.WriteLine($"[Response from remote A2A agent]: {response.GetOutputText()}");
}
finally
{
    // Clean up the server-side agent version regardless of success or failure.
    projectClient.AgentAdministrationClient
        .DeleteAgentVersion(agentName: agentVersion.Name, agentVersion: agentVersion.Version);
    Console.WriteLine("Agent version deleted.");
}
Enter fullscreen mode Exit fullscreen mode

The gotcha that caught me first: A2APreviewTool needs a ProjectConnectionId, not a raw URL. Resolve the connection name to a full connection object with projectClient.Connections.GetConnection(connectionName) at startup. The .Id property is what the tool uses.

The connection type check deserves a clear explanation, because the official docs sample and the C# SDK spell the connection type differently and it looks like a contradiction. The official docs sample does string.Equals(a2aConnection.Type.ToString(), "RemoteA2A"), comparing against the string "RemoteA2A", which is the backend REST connection category name that comes back from the Foundry portal API. That string does not exist as a named constant in the C# SDK. In Azure.AI.Projects 2.0.1, the typed value is ConnectionType.RemoteTool, whose .ToString() is "RemoteTool_Preview". Same concept, completely different strings. The code here uses the typed SDK value, ConnectionType.RemoteTool, which is correct. A RemoteTool connection carries the A2A endpoint URI inside the connection record automatically; any other type doesn't, so you also need to set a2aTool.BaseUri explicitly. The code handles that branch inline.

When wiring up the responses client, GetProjectResponsesClientForAgent expects an AgentReference(name, version), not a bare string. The single-string overload shown in some documentation does not exist in Azure.AI.Extensions.OpenAI 2.0.0. Use new AgentReference(agentVersion.Name, agentVersion.Version) as the code shows.

The OpenAI.Responses types used here (CreateResponseOptions, ResponseToolChoice, ResponseResult, and friends) carry [Experimental("OPENAI001")] in the SDK. Without <NoWarn>$(NoWarn);OPENAI001</NoWarn> in your PropertyGroup, the build fails with errors rather than warnings. Add that suppression before you try to compile; the example includes it and the comment explains why.

Example-03 uses Azure.AI.Projects 2.0.1 directly rather than via Microsoft.Agents.AI.Foundry. One thing worth knowing: on net10.0, DefaultAzureCredential resolves without a separate Azure.Identity package reference. Azure.Core 1.53.0 embeds the identity types for that target, so the using Azure.Identity; directive resolves through the transitive chain. You do not need an extra <PackageReference>.

Before you build anything for production on this: the A2A feature is currently in public preview. The Azure.AI.Projects 2.0.1 NuGet package is a stable GA release, so that's not the issue. The issue is the A2A feature itself, which Microsoft's official docs flag with a preview banner and the note that it is not recommended for production workloads. A2APreviewTool is not a holdover class name from an earlier API; it reflects where the feature sits today. No SLA, behaviour subject to change. For demos and internal tooling, fine. For customer-facing infrastructure, wait for GA or decide consciously that you accept the risk.

ResponseToolChoice.CreateRequiredChoice() is worth using when you want to guarantee the agent actually calls the remote endpoint rather than answering from its own context.

When does A2A actually earn its overhead? When the specialist is owned by a different team and deployed independently, when multiple callers need to reach the same endpoint without sharing a process, or when you're integrating with a non-Microsoft agent that speaks the A2A protocol. You might also have an agent that you want to expose to multiple consumers (internally or externally) where it makes sense to call the agent through A2A with authentication.

For same-team, same-codebase work, AsAIFunction() beats A2A on every practical dimension.

Personally, I'm currently exploring using A2A from an n8n workflow calling an agent that knows how to provide me with a daily brief. The daily brief agent has an agent card and sits behind authentication, which n8n knows how to call.

Choosing the pattern in code

If you're onboarding a team to these patterns, it helps to make the decision explicit in code rather than leaving it in a doc somewhere that nobody reads. The following example captures a simplistic approach to selecting a scenario.

I was unsure to whether to include this code in the post - it could probably also have been a matrix with options and green checks / red x's, but then it would be something you put in your docs (that nobody reads) and I wanted to have something like this in code ... here you go:

// example-04: Static decision harness — recommends an agent composition pattern

// ---- Scenarios ----

var scenarios = new (string Label, AgentScenario Scenario)[]
{
    (
        "same process, same model, 1 specialist",
        new AgentScenario(
            SpecialistsNeedDifferentModels: false,
            IndependentDeploymentRequired: false,
            CrossProcessOrCrossSubscription: false,
            NumberOfSpecialists: 1)
    ),
    (
        "same process, different models, 2 specialists",
        new AgentScenario(
            SpecialistsNeedDifferentModels: true,
            IndependentDeploymentRequired: false,
            CrossProcessOrCrossSubscription: false,
            NumberOfSpecialists: 2)
    ),
    (
        "different models, independent deployment needed",
        new AgentScenario(
            SpecialistsNeedDifferentModels: true,
            IndependentDeploymentRequired: true,
            CrossProcessOrCrossSubscription: false,
            NumberOfSpecialists: 2)
    ),
    (
        "cross-subscription specialist",
        new AgentScenario(
            SpecialistsNeedDifferentModels: false,
            IndependentDeploymentRequired: false,
            CrossProcessOrCrossSubscription: true,
            NumberOfSpecialists: 1)
    ),
};

foreach (var (label, scenario) in scenarios)
{
    var rec = RecommendPattern(scenario);
    Console.WriteLine($"Scenario: {label}{rec.Pattern}: \"{rec.Rationale}\"");
}

// ---- Decision logic ----

static Recommendation RecommendPattern(AgentScenario scenario)
{
    // Rule 1: cross-process or cross-subscription boundary — only A2A can reach it.
    if (scenario.CrossProcessOrCrossSubscription)
        return new(CompositionPattern.A2A,
            "Cross-subscription boundary requires A2A.");

    // Rule 2: independently deployed specialist — use A2A to avoid coupling deployments.
    if (scenario.IndependentDeploymentRequired)
        return new(CompositionPattern.A2A,
            "Independent deployment requires A2A; use A2APreviewTool.");

    // Rule 3: multiple specialists with different model requirements — AsAIFunction lets
    // each specialist use its own model connection while staying in-process.
    if (scenario.SpecialistsNeedDifferentModels && scenario.NumberOfSpecialists >= 2)
        return new(CompositionPattern.AsAIFunction,
            "Use AsAIFunction() to compose specialists in-process.");

    // Rule 4: multiple specialists, same model — AsAIFunction works; evaluate whether
    // a single agent with multiple tools is simpler before committing to orchestration.
    if (scenario.NumberOfSpecialists >= 2)
        return new(CompositionPattern.AsAIFunction,
            "Use AsAIFunction() to compose specialists in-process; " +
            "consider single-agent-with-tools first if specialists share a model.");

    // Rule 5: one specialist, same process — no orchestration layer needed.
    return new(CompositionPattern.SingleAgentWithTools,
        "One agent with tools handles this; no orchestration needed.");
}

// ---- Types (must follow all top-level executable statements in C# top-level programs) ----

record AgentScenario(
    bool SpecialistsNeedDifferentModels,
    bool IndependentDeploymentRequired,
    bool CrossProcessOrCrossSubscription,
    int NumberOfSpecialists
);

enum CompositionPattern
{
    SingleAgentWithTools,
    AsAIFunction,
    A2A
}

record Recommendation(CompositionPattern Pattern, string Rationale);
Enter fullscreen mode Exit fullscreen mode

This is pure C#, no Foundry SDK required. The value is as executable documentation: it makes the selection criteria visible and gives you something concrete to point to in a code review when someone reaches for A2APreviewTool and AsAIFunction() would have been fine.

Foundry Workflows retirement

If you use the Foundry visual workflow designer, the date to note is December 1, 2026. After that, the portal designer and in-portal execution stop working. The orchestration patterns themselves (sequential pipelines, group chat, human-in-the-loop) are not disappearing. They carry over to Agent Framework, which supports both declarative YAML and code-first.

The migration options Microsoft recommends:

Your situation Where to go
Sequential or group-chat orchestration, want code-first Microsoft Agent Framework (YAML or C#)
Visual designer is a requirement for your team Azure Logic Apps
One agent calling another, no formal workflow needed A2A

Before you do anything else, export your workflow YAML from the portal. The YAML view gives you a portable definition, and in many cases you can take that exported YAML into an Agent Framework project and run it as a hosted agent with minimal changes. That's the closest thing to a lift-and-shift that exists.

One pattern worth flagging separately: group-chat orchestration (where agents hand off to each other dynamically based on context) is documented in Agent Framework and the portal supports it today. But there is no published .NET sample for group chat at time of writing. If that's your target pattern, watch the agent-framework GitHub; samples appear regularly. For the migration path with the most stable .NET footing right now, sequential composition via AsAIFunction() or the A2A tool is a good place to start.

Observability

Be upfront with your team about this: multi-agent workflow tracing is currently in preview. Single-agent OpenTelemetry integration is GA and works well. Step 07 in the Foundry samples covers it. But cross-agent trace correlation, the ability to see a ResearchAgent → OrchestratorAgent → WriterAgent span tree in a single trace, is not yet production-ready.

For now, instrument each agent individually and correlate by session ID when you need to stitch runs together. It's a manual process and honestly a bit annoying, but running blind is worse. Keep an eye on the workflow tracing docs as multi-agent observability matures. It's the obvious next step once the patterns themselves have stabilised.

Before you go

The agent-as-function-tool pattern handles most real-world multi-agent scenarios in .NET without introducing deployment complexity. A2A is the right answer when you hit an actual organisational or process boundary (this could be an integration between an off-the-shelf workflow engine and your custom agent). Everything else is usually just adding hops for its own sake, and every hop is a model call you're paying for.

Start with the simplest thing that can work. One agent, good tools, a clear system prompt. You can always split later when the need arises.

Code Examples

You can find the examples and a markdown version of this article on my github. On github you'll find that the article has a few more notes in the code, which I intentionally left out here as its a bit verbose.
Code examples for this article

Sources

Top comments (0)