Guidance

πŸ€” Tools vs Sub-Agents

The most important architectural decision in multi-agent systems: when to use a simple C# function tool vs a full LLM-powered sub-agent.

Both tools and sub-agents extend what an agent can do β€” but they have fundamentally different cost, latency, and capability profiles. Choosing wrong leads to either over-engineered systems (agent for everything) or brittle ones (expecting a C# function to reason for you).

Quick Decision Guide

Question→ Use a Tool→ Use a Sub-Agent
Does it need LLM reasoning?❌ No β€” deterministic outputβœ… Yes β€” judgment required
Does it benefit from a focused system prompt?❌ No persona neededβœ… Domain expertise via instructions
Does it call external APIs or databases?βœ… Perfect fit❌ Overkill β€” use a tool
Does it need to make multi-step decisions?❌ Fixed logic onlyβœ… Planning + tool-calling loop
Does it need to call other tools itself?❌ A tool cannot call toolsβœ… Agents can have their own tools
Is speed critical (<100 ms)?βœ… No LLM overhead❌ Adds an LLM round-trip
Will multiple agents reuse it?βœ… Share one C# functionβœ… Share an AIAgent instance
Does the output vary with context/tone?❌ Returns fixed dataβœ… Adapts output to situation

Side-by-Side Code Comparison

Same task (get weather) implemented both ways β€” so you can see the difference clearly.

βœ… As a Tool (correct)

Weather lookup is deterministic β€” always the same result for the same city. No reasoning needed.

// A tool is just a C# method.
// AIFunctionFactory.Create() wraps it.
string GetWeather(
    [Description("City name")] string city)
    => $"Weather in {city}: 22Β°C, sunny.";

AIAgent agent = azureClient
    .GetChatClient("gpt-4o-mini")
    .AsAIAgent(
        instructions: "Answer weather questions.",
        tools: [AIFunctionFactory.Create(GetWeather)]);

// Agent calls GetWeather() internally,
// then synthesises the answer.
Console.WriteLine(await agent.RunAsync(
    "What's the weather in Paris?"));
  • 1 LLM call + 1 C# function call
  • Fast, cheap, predictable
❌ As a Sub-Agent (overkill)

Creating a full agent just to look up weather is wasteful β€” it adds an extra LLM round-trip with no benefit.

// ❌ DON'T do this for simple lookups!
AIAgent weatherAgent = azureClient
    .GetChatClient("gpt-4o-mini")
    .AsAIAgent(
        instructions: "Look up weather.",
        name: "WeatherAgent",
        description: "Gets weather data.");

AIAgent orchestrator = azureClient
    .GetChatClient("gpt-4o-mini")
    .AsAIAgent(
        instructions: "Answer questions.",
        tools: [weatherAgent.AsAIFunction()]);

// 2 LLM calls for something a C# string
// could handle in 0 ms. Avoid this.
Console.WriteLine(await orchestrator.RunAsync(
    "What's the weather in Paris?"));
  • 2 LLM calls β€” double the cost & latency
  • No quality improvement over a simple tool

When a Sub-Agent IS Worth the Extra LLM Call

// βœ… Sub-agent IS justified here: the task requires domain reasoning,
//    a focused persona, and its own tool-calling loop.

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

// These are TOOLS β€” deterministic lookups, no reasoning needed.
string GetCustomerOrders(
    [Description("Customer email")] string email)
    => $"Orders for {email}: #1042 (delivered), #1087 (processing), #1101 (shipped).";

string GetProductDetails(
    [Description("Product ID")] string productId)
    => $"Product {productId}: Premium Widget, Β£49.99, In Stock: 12 units, Rating: 4.7/5.";

string ApplyDiscount(
    [Description("Order ID")] string orderId,
    [Description("Discount percentage (1-20)")] int percent)
    => $"Applied {percent}% discount to order #{orderId}. New total: Β£{49.99 * (1 - percent / 100.0):F2}.";

var azureClient = new AzureOpenAIClient(
    new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")!),
    new AzureCliCredential());

// βœ… Sub-agent IS worth the extra LLM call here because:
// 1. Needs a specialist persona (empathetic, policy-aware)
// 2. Makes multi-step decisions: look up account β†’ assess situation β†’ decide on discount
// 3. The discount decision requires judgment β€” not a fixed rule
AIAgent retentionAgent = azureClient
    .GetChatClient("gpt-4o-mini")
    .AsAIAgent(
        instructions: """
            You are a customer retention specialist. Your goal is to keep customers happy
            and prevent churn. When a customer is unhappy:
            1. Look up their order history first β€” understand their purchase pattern.
            2. Check the specific product they're concerned about.
            3. Use your best judgment to offer a discount (5-20%) based on their loyalty.
               Long-term customers with multiple orders deserve more generous offers.
            4. Apply the discount if the customer accepts.
            Be warm, empathetic, and proactive β€” don't wait for the customer to ask.
            """,
        name: "RetentionAgent",
        description: "Handles unhappy customers and applies retention discounts. Provide the customer email and their complaint.",
        tools:
        [
            AIFunctionFactory.Create(GetCustomerOrders),
            AIFunctionFactory.Create(GetProductDetails),
            AIFunctionFactory.Create(ApplyDiscount)
        ]);

// The orchestrator (main agent) delegates retention cases to the specialist.
AIAgent supportOrchestrator = azureClient
    .GetChatClient("gpt-4o-mini")
    .AsAIAgent(
        instructions: """
            You are the main customer support agent. Handle general questions yourself.
            For unhappy customers who mention cancelling, refunds, or disappointment β€”
            delegate to RetentionAgent immediately with their email and complaint details.
            """,
        tools: [retentionAgent.AsAIFunction()]);

// The orchestrator recognises this as a retention case and delegates.
Console.WriteLine(await supportOrchestrator.RunAsync(
    "My email is jane@example.com. I've been a customer for 2 years but my last order " +
    "was broken and I'm thinking of cancelling my subscription."));

πŸ—ΊοΈ Pattern Catalog

An orchestrator classifies input and routes to the right specialist. See Customer Support Triage.

User β†’ [Orchestrator]
           β”œβ”€β”€ billing? β†’ [BillingAgent]
           β”œβ”€β”€ technical? β†’ [TechnicalAgent]
           └── account? β†’ [AccountAgent]

When to use: Input can be classified into categories, each requiring different expertise or persona.

Each agent processes and enriches the output of the previous one. See Research Pipeline.

Input β†’ [Stage1Agent] β†’ [Stage2Agent] β†’ [Stage3Agent] β†’ Output
                    (research)   (fact-check)   (write)

When to use: Complex task can be decomposed into sequential refinement steps, each requiring different expertise.

An orchestrator fans out to multiple specialists simultaneously and synthesises their independent perspectives.

                β”Œβ”€ [SecurityAgent]   ──┐
Input β†’ [Coordinator] ─── [PerformanceAgent] β”€β”œβ”€ synthesise β†’ Output
                └─ [StyleAgent]      β”€β”€β”˜

When to use: Task needs multiple independent reviews (code review, multi-perspective analysis). Use Task.WhenAll to fan out in parallel for speed.

A top-level orchestrator delegates to sub-orchestrators that themselves coordinate their own specialists. Enables very large-scale systems.

[TopOrchestrator]
    β”œβ”€β”€ [SalesOrchestrator]
    β”‚       β”œβ”€β”€ [LeadAgent]
    β”‚       └── [PricingAgent]
    └── [SupportOrchestrator]
            β”œβ”€β”€ [BillingAgent]
            └── [TechnicalAgent]

When to use: System is large enough that a single orchestrator would have too many tools. Decompose into sub-orchestrators by department or domain.

⚠️ Anti-Patterns to Avoid

❌ The "God Tool"

A tool function that does complex reasoning internally β€” calling an LLM, parsing responses, making decisions.

// ❌ Wrong: a "tool" that secretly calls an LLM
string AnalyseCustomerSentiment(string text)
{
    // calls OpenAI internally β€” hidden LLM!
    var response = openAiClient.Complete(text);
    return response.Choices[0].Text;
}

Fix: Make it a sub-agent with proper instructions and tool registration. Hidden LLM calls bypass observability and error handling.

❌ Flat Everything

One giant agent with 20+ tools, no sub-agents, handling everything in one context window.

// ❌ Wrong: one agent, 20 tools, 3000 word
//    system prompt. Context is polluted.
AIAgent agent = client.AsAIAgent(
    instructions: "...3000 words...",
    tools: [tool1, tool2, ..., tool20]);

Fix: Group related tools into specialist sub-agents. Smaller context β†’ better focus β†’ higher quality.

⚠️ Agent for Everything

Using a full sub-agent for tasks that a C# function could handle in milliseconds at zero LLM cost.

// ⚠️ Overkill for a simple string format:
AIAgent formatterAgent = client.AsAIAgent(
    name: "DateFormatterAgent",
    instructions: "Format dates as DD/MM/YYYY.");

Fix: DateTime.ToString("dd/MM/yyyy") β€” a tool that calls a C# method. Costs nothing.

⚠️ Vague Sub-Agent Descriptions

The orchestrator uses the description to decide when to call a sub-agent. Vague descriptions cause mis-routing.

// ❌ Vague β€” the orchestrator won't know when to call this:
name: "HelperAgent", description: "Helps with things."

// βœ… Specific β€” clear routing signal:
name: "BillingAgent",
description: "Handles invoice queries, payment problems, " +
             "refund requests, and subscription billing issues."

Rule of Thumb

🟒 Use a tool if the answer can be computed or looked up without LLM reasoning.
πŸ”΅ Use a sub-agent if the task requires a persona, multi-step planning, or judgment.
πŸ’‘ When in doubt: start with a tool. Promote it to a sub-agent when you find yourself writing complex logic inside the tool body that would benefit from LLM reasoning.

See These Patterns in Action