Migrating C# -> Microsoft Agent Framework

In a previous blog post I ported a Python application to C#. However, I did not take advantage of the Microsoft Agent Framework (MAF). In this admittedly long post, I’ll migrate that code to MAF.

Note, the updated source code for the .NET version of this demo application is available at https://github.com/JesseLiberty/blogMigration—public

The Microsoft Agent Framework (MAF) is an open-source software development kit (SDK) designed to facilitate the creation of agentic AI solutions and multi-agent workflows, primarily utilizing Python or C#.

Enterprise Focus

The framework is tailored to meet the complexities of deploying agentic AI in enterprise settings. It incorporates observability, security, and compliance.

Multi-Agent Workflows

MAF supports advanced orchestration of multiple agents. This capability enables different AI agents to collaborate effectively, which is crucial for applications that demand coordinated efforts across various tasks and functions. This is perfect for the application we’ve been working on.

Integration with Azure

MAF is deeply integrated with Azure services, allowing developers to harness cloud capabilities for enhanced scalability and performance. This integration ensures that applications built with MAF can efficiently handle varying workloads and user demands.

Agentic AI refers to AI systems capable of acting autonomously and making decisions based on their environment. The Microsoft Agent Framework simplifies the development and management of these systems by providing a comprehensive set of tools and libraries

With all that in mind, let’s look at modifying the raw C# to leverage the MAF…

Note: as part of this port to Microsoft Agent Framework I made good use of the extraordinary utility MAF-Doctor, which you can learn more about in my video interview with its author here, or the podcast here.

File Changes:

Prompts.cs Split each node’s prompt into a static instructions constant (the agent’s system prompt) — removed the {token} placeholder templating.

ResearcherAgent.cs Now a ChatClientAgent with Tavily attached as a tool — the model searches and summarizes in one run. Deleted ~40 lines of manual tool invocation + JsonDocument parsing + the second summarize call.

BloggerChain.cs LLM fallback uses structured output (RunAsync) — removed the “`-fence stripping and JsonSerializer.Deserialize. Deterministic routing preserved; comments clarify the workflow edges are the real router.

AuthorChain.cs Converted to a ChatClientAgent; role in Instructions, state as the per-turn message.

ReviewerChain.cs Converted to a ChatClientAgent + correctness fix: a failed review no longer auto-APPROVEDs — it requests revision instead (still bounded by MaxRevisions).

Program.cs Added .UseFunctionInvocation() to the IChatClient pipeline — required for the researcher’s tool calls to actually execute.

BlogWorkflow.cs Switched to RunStreamingAsync + WatchStreamAsync() to stream executor lifecycle events live; identical topology.

Let’s start with Prompt.cs. The comments show all the changes and the justification:

namespace BlogMigration;

/// <summary>
/// Prompt library for the blog-creation agents.
///
/// MAF idiom change (was: one big template per node mixing role + data):
/// each agent now has a *static* INSTRUCTIONS string (its system prompt / role)
/// that is set once on the <c>ChatClientAgent</c>, while the *dynamic* state
/// (task, findings, draft, review notes) is passed per-turn as the user message.
/// Separating the durable role from the volatile input is the recommended
/// Microsoft Agent Framework pattern: it keeps the system prompt cacheable,
/// lets the model treat instructions with higher priority than user input,
/// and removes the brittle <c>string.Replace("{token}", ...)</c> templating.
/// </summary>
public static class Prompts
{
    /// <summary>
    /// Blogger system prompt. The concrete state is supplied as the user message;
    /// the decision is returned via MAF structured output (typed
    /// <see cref="BloggerDecision"/>), so this prompt no longer needs to describe
    /// the exact JSON shape or beg the model for "no extra text" — the schema is
    /// enforced by the framework.
    /// </summary>
    public const string BloggerInstructions = """
You are a blogger managing a blog post creation workflow.

Your goal is to ensure a clear, engaging, and valuable blog post targeted at
software developers. Based on the current workflow state provided in the user
message, decide the next step.

Decision Rules:
- If no research exists, choose "researcher"
- If research exists but no draft, choose "author"
- If a draft exists and the reviewer said "APPROVED", choose "END"
- If the draft needs revision, choose "author"
- If revision_number >= 4, choose "END"

Return the next step and a brief task description.
""";

    /// <summary>
    /// Researcher system prompt. The topic to research is supplied as the user
    /// message. The Tavily web-search tool is attached to the agent, so the model
    /// itself decides when to call it and then summarises the results — there is
    /// no longer any hand-written search-call + JSON-parsing orchestration.
    /// </summary>
    public const string ResearcherInstructions = """
You are a researcher for a technical blog
focused on .NET and AI with examples in C# and Python.

You have access to a web-search tool. Use it to find relevant, up-to-date
insights for the topic given in the user message. Focus on:
- Key trends, challenges, or innovations
- Real-world use cases
- Supporting data or quotes from credible sources
- Simple explanations
- Short code examples in C# or Python

Call the search tool as needed, then summarize your findings concisely.
""";

    /// <summary>
    /// Author system prompt. The task, research findings, current draft and review
    /// notes are supplied as the user message each turn.
    /// </summary>
    public const string AuthorInstructions = """
You are a professional blogger.

The user message contains the main task, the research findings, the current
draft (if any) and any reviewer notes.

Instructions:
- If this is the first draft (no current draft), create a comprehensive post based on the findings
- If there is a current draft and review notes, revise the draft to address all feedback
- Use a professional tone
- Make the post concise (aim for 250-500 words)

Write the complete post.
""";

    /// <summary>
    /// Reviewer system prompt. The task and the draft to review are supplied as the
    /// user message.
    /// </summary>
    public const string ReviewerInstructions = """
You are a reviewer evaluating content for a blog post.

The user message contains the main task and the draft to review.

Evaluate the draft based on:
1. Hook Strength – Does the opening grab attention?
2. Clarity – Is the message easy to understand?
3. Value – Does the post offer real insights or lessons?
4. Structure – Are paragraphs short?
5. Tone – Is it authentic and professional?

Respond with one of:
- If the draft is satisfactory (minor issues are okay): "APPROVED - [brief positive comment]"
- If the draft needs improvement: provide specific, actionable feedback for revision
""";
}

With that in place, we can turn to Researcher. The most important change here is the conversion to a ChatClientAgent – the heart of MAF. Tavily is now attached as a tool. This allowed me to remove about 40 lines of tool invocation and JSON parsing. It also eliminated a second call as shown in the comments:

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

namespace BlogMigration;

/// <summary>
/// Researcher backed by a Microsoft Agent Framework <see cref="ChatClientAgent"/>.
///
/// MAF idiom change: previously this class manually invoked the Tavily tool,
/// hand-parsed the JSON response, then made a SECOND LLM call to summarise it.
/// Now the Tavily function is registered as a *tool on the agent*, so the model
/// itself decides when to search and produces the summary in a single agent run.
/// This requires the underlying <see cref="IChatClient"/> to have
/// function-invocation middleware enabled (wired in <c>Program.cs</c> via
/// <c>UseFunctionInvocation()</c>), which actually executes the tool calls the
/// model requests.
/// </summary>
public class ResearcherAgent : IResearcherAgent
{
    // The agent is built once and reused for every research turn. It is stateless
    // across turns (no AgentSession is retained), which matches the original
    // per-call behaviour while gaining tool-calling for free.
    private readonly ChatClientAgent _agent;

    public ResearcherAgent(IChatClient llm, ChatOptions chatOptions, AIFunction tavilyTool)
    {
        _agent = new ChatClientAgent(llm, new ChatClientAgentOptions
        {
            // Name surfaces in OpenTelemetry traces and agent logs.
            Name = "Researcher",
            ChatOptions = new ChatOptions
            {
                // Static role/system prompt lives here instead of being concatenated
                // into every request body.
                Instructions = Prompts.ResearcherInstructions,
                // Preserve the original sampling/cost settings.
                Temperature = chatOptions.Temperature,
                MaxOutputTokens = chatOptions.MaxOutputTokens,
                // Attaching the tool lets the model call it autonomously.
                Tools = [tavilyTool],
            },
        });
    }

    /// <summary>Execute research by letting the agent search and summarise.</summary>
    public async Task<string> InvokeAsync(string query)
    {
        try
        {
            // A single agent run: the model may call tavily_search one or more
            // times, read the results, and return a concise summary as its text.
            AgentResponse response = await _agent.RunAsync(query);
            string summary = response.Text;

            return !string.IsNullOrEmpty(summary)
                ? summary
                : $"Research completed on: {query}. Key information has been gathered from web sources.";
        }
        catch (Exception e)
        {
            Console.WriteLine($"Research error: {e.Message}");
            return $"Research completed on: {query}. Key information has been gathered from web sources.";
        }
    }

    /// <summary>Research node that gathers information.</summary>
    public async Task<ResearchState> ResearchNodeAsync(ResearchState state)
    {
        Console.WriteLine("\n>>>RESEARCHER");

        string subTask = !string.IsNullOrEmpty(state.CurrentSubTask) ? state.CurrentSubTask : state.MainTask;
        Console.WriteLine($"Researching: {subTask}");

        string findings;
        try
        {
            findings = await InvokeAsync(subTask);
            string preview = findings.Length > 100 ? findings[..100] : findings;
            Console.WriteLine($"Found: {preview}...");
        }
        catch (Exception e)
        {
            Console.WriteLine($"Research error: {e.Message}");
            findings = $"Research on {subTask} - information gathered";
        }

        state.ResearchFindings.Add(findings);
        return state;
    }
}

Let’s turn to the Author. Again, we convert to ChatClientAgent,

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

namespace BlogMigration;

/// <summary>
/// Author chain backed by a Microsoft Agent Framework <see cref="ChatClientAgent"/>.
///
/// MAF idiom change: the writing role now lives in the agent's Instructions
/// (set once), and only the volatile state (task, findings, draft, review notes)
/// is sent as the per-turn user message — replacing the previous
/// <c>string.Replace("{token}", ...)</c> templating against the raw IChatClient.
/// </summary>
public class AuthorChain : IAuthorChain
{
    private readonly ChatClientAgent _agent;

    public AuthorChain(IChatClient llm, ChatOptions chatOptions)
    {
        _agent = new ChatClientAgent(llm, new ChatClientAgentOptions
        {
            Name = "Author",
            ChatOptions = new ChatOptions
            {
                Instructions = Prompts.AuthorInstructions,
                Temperature = chatOptions.Temperature,
                MaxOutputTokens = chatOptions.MaxOutputTokens,
            },
        });
    }

    public async Task<string> InvokeAsync(ResearchState state)
    {
        List<string> research = state.ResearchFindings;
        string researchText = research.Count > 0 ? string.Join("\n\n", research) : "No research available.";

        // Per-turn input only — the role/instructions are already on the agent.
        string message = $"""
            Main Task: {state.MainTask}

            Research Findings:
            {researchText}

            Current Draft: {(string.IsNullOrEmpty(state.Draft) ? "(none — write the first draft)" : state.Draft)}

            Review Notes: {(string.IsNullOrEmpty(state.ReviewNotes) ? "(none)" : state.ReviewNotes)}
            """;

        try
        {
            AgentResponse response = await _agent.RunAsync(message);
            string content = response.Text;
            return !string.IsNullOrEmpty(content) ? content : "Draft in progress...";
        }
        catch (Exception e)
        {
            Console.WriteLine($"Author error: {e.Message}");
            return "Error generating draft. Please try again.";
        }
    }

    /// <summary>Author node that creates or revises draft.</summary>
    public async Task<ResearchState> AuthorNodeAsync(ResearchState state)
    {
        Console.WriteLine("\n>>>Author");

        string draft = await InvokeAsync(state);
        Console.WriteLine($"Draft created: {draft.Length} characters");

        state.Draft = draft;
        state.RevisionNumber += 1;
        return state;
    }
}

To close the circle, let’s look at the changes in Reviewer. In addition to changing to ChatClientAgent, we fix the code so that if a review fails it no longer approves, instead it requests revision (which was supposed to happen in the first place):

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

namespace BlogMigration;

/// <summary>
/// Reviewer chain backed by a Microsoft Agent Framework <see cref="ChatClientAgent"/>.
///
/// MAF idiom change: the evaluation role lives in the agent's Instructions; only
/// the task + draft are sent as the per-turn user message.
///
/// Correctness fix: the previous version's <c>catch</c> returned
/// "APPROVED - Error in review..." — meaning any transient LLM/transport failure
/// would silently approve an unreviewed draft. It now returns a revision request
/// instead, so a failed review re-loops to the author (bounded by
/// <see cref="ResearchState.MaxRevisions"/>) rather than shipping unchecked content.
/// </summary>
public class ReviewerChain : IReviewerChain
{
    private readonly ChatClientAgent _agent;

    public ReviewerChain(IChatClient llm, ChatOptions chatOptions)
    {
        _agent = new ChatClientAgent(llm, new ChatClientAgentOptions
        {
            Name = "Reviewer",
            ChatOptions = new ChatOptions
            {
                Instructions = Prompts.ReviewerInstructions,
                Temperature = chatOptions.Temperature,
                MaxOutputTokens = chatOptions.MaxOutputTokens,
            },
        });
    }

    public async Task<string> InvokeAsync(ResearchState state)
    {
        string draft = state.Draft;
        int revisionNum = state.RevisionNumber;

        if (draft.Trim().Length < 100)
        {
            return "APPROVED - Draft is minimal but acceptable.";
        }

        if (revisionNum >= ResearchState.MaxRevisions)
        {
            return "APPROVED - Maximum revisions reached. The report is satisfactory.";
        }

        // Per-turn input only — the evaluation criteria are on the agent.
        string message = $"""
            Main Task: {state.MainTask}

            Draft to Review:
            {draft}
            """;

        try
        {
            AgentResponse response = await _agent.RunAsync(message);
            string content = response.Text;
            return !string.IsNullOrEmpty(content) ? content : "APPROVED";
        }
        catch (Exception e)
        {
            // Do NOT approve on failure — that would ship an unreviewed draft.
            // Returning feedback (not "APPROVED") routes back to the author for
            // another attempt; the revision cap still guarantees termination.
            Console.WriteLine($"Review error: {e.Message}");
            return "Review could not be completed due to a transient error. Please revise and resubmit the draft.";
        }
    }

    /// <summary>Node that reviews the draft.</summary>
    public async Task<ResearchState> ReviewerNodeAsync(ResearchState state)
    {
        Console.WriteLine("\n>>REVIEWER");

        string review = await InvokeAsync(state);
        string preview = review.Length > 100 ? review[..100] : review;
        Console.WriteLine($"Review: {preview}...");

        bool isApproved = review.ToUpperInvariant().Contains("APPROVED");

        if (isApproved)
        {
            Console.WriteLine("\u2713 Draft APPROVED");
            state.ReviewNotes = "APPROVED";
            state.NextStep = "END";
        }
        else
        {
            Console.WriteLine("\u2717 Revisions needed");
            state.ReviewNotes = review;
            state.NextStep = "author";
        }

        return state;
    }
}

We’re ready to look at the changes to Blogger…

using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

namespace BlogMigration;

/// <summary>
/// Blogger decision chain.
///
/// MAF idiom change: the LLM fallback used to ask the model for raw JSON, then
/// manually strip ```-fences and call <c>JsonSerializer.Deserialize</c>. That is
/// now replaced by MAF structured output — <c>RunAsync&lt;BloggerDecision&gt;</c>
/// returns a typed, schema-validated <see cref="BloggerDecision"/> directly.
///
/// Routing note: the actual control flow is owned by the workflow edges in
/// <see cref="BlogWorkflow"/> (Blogger → Researcher → Author → Reviewer with a
/// bounded revision loop). The <c>NextStep</c> this class computes is advisory;
/// its still-meaningful output is <c>CurrentSubTask</c>, which seeds the
/// researcher. The deterministic rules below are kept because they faithfully
/// preserve the original LangGraph decision logic and avoid an LLM call in the
/// common cases.
/// </summary>
public class BloggerChain : IBloggerChain
{
    // Built once and reused. Holds the static Blogger instructions; the volatile
    // state is passed per-turn as the user message.
    private readonly ChatClientAgent _agent;

    // Web-style options are sufficient: BloggerDecision carries explicit
    // [JsonPropertyName] attributes (next_step / task_description) that drive the
    // generated schema regardless of naming policy.
    private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);

    public BloggerChain(IChatClient llm, ChatOptions chatOptions)
    {
        _agent = new ChatClientAgent(llm, new ChatClientAgentOptions
        {
            Name = "Blogger",
            ChatOptions = new ChatOptions
            {
                Instructions = Prompts.BloggerInstructions,
                Temperature = chatOptions.Temperature,
                MaxOutputTokens = chatOptions.MaxOutputTokens,
            },
        });
    }

    public async Task<BloggerDecision> InvokeAsync(ResearchState state)
    {
        List<string> research = state.ResearchFindings;
        string researchText = research.Count > 0 ? string.Join("\n", research) : "No research yet.";
        int revision = state.RevisionNumber;
        bool hasResearch = research.Count > 0;
        bool hasDraft = !string.IsNullOrWhiteSpace(state.Draft);
        string review = state.ReviewNotes;

        if (review.ToUpperInvariant().Contains("APPROVED") && hasDraft)
        {
            Console.WriteLine("Blogger: Draft approved, ending workflow");
            return new BloggerDecision("END", "Report approved and complete");
        }

        if (!hasResearch)
        {
            Console.WriteLine("Blogger: No research yet, directing to researcher");
            return new BloggerDecision("researcher", $"Research the topic: {state.MainTask}");
        }

        if (hasResearch && !hasDraft)
        {
            Console.WriteLine("Blogger: Have research, creating first draft");
            return new BloggerDecision("author", "Write the first draft based on research findings");
        }

        if (hasDraft && string.IsNullOrEmpty(review))
        {
            Console.WriteLine("Blogger: Have draft, sending to reviewer");
            return new BloggerDecision("reviewer", "Prepare draft for review");
        }

        if (!string.IsNullOrEmpty(review) && !review.ToUpperInvariant().Contains("APPROVED") && revision < ResearchState.MaxRevisions)
        {
            Console.WriteLine($"Blogger: Revision {revision}, sending back to author");
            return new BloggerDecision("author", "Revise the draft based on review feedback");
        }

        // Max revisions reached
        if (revision >= ResearchState.MaxRevisions)
        {
            Console.WriteLine("Blogger: Max revisions reached! Ending");
            return new BloggerDecision("END", "Maximum revisions reached! Finalizing report");
        }

        // LLM decision as fallback. The dynamic state is the user message; the
        // static role lives in the agent's Instructions. MAF structured output
        // hands back a typed BloggerDecision — no fenced-block cleanup, no manual
        // JsonSerializer.Deserialize.
        string stateSummary = $"""
            Current Task: {state.MainTask}
            Research Findings: {researchText}
            Blog Draft: {(string.IsNullOrEmpty(state.Draft) ? "No draft yet." : state.Draft)}
            Reviewer Feedback: {(string.IsNullOrEmpty(review) ? "No review yet." : review)}
            Revision Number: {revision}
            """;

        try
        {
            AgentResponse<BloggerDecision> response =
                await _agent.RunAsync<BloggerDecision>(stateSummary, serializerOptions: _jsonOptions);

            BloggerDecision decision = response.Result;
            if (decision is not null && !string.IsNullOrEmpty(decision.NextStep))
            {
                return decision;
            }
        }
        catch (Exception e)
        {
            Console.WriteLine($"LLM decision error: {e.Message}");
        }

        // Final fallback - continue with author
        Console.WriteLine("Blogger: Using final fallback - continuing with author");
        return new BloggerDecision("author", "Continue with draft creation");
    }

    /// <summary>Blogger decides the next step.</summary>
    public async Task<ResearchState> BloggerNodeAsync(ResearchState state)
    {
        Console.WriteLine("\n>>>Blogger");

        BloggerDecision decision = await InvokeAsync(state);

        string nextStep = string.IsNullOrEmpty(decision.NextStep) ? "researcher" : decision.NextStep;
        string taskDesc = string.IsNullOrEmpty(decision.TaskDescription) ? "Continue work" : decision.TaskDescription;

        Console.WriteLine($"Decision: {nextStep}");
        Console.WriteLine($"Task: {taskDesc}");

        state.NextStep = nextStep;
        state.CurrentSubTask = taskDesc;
        return state;
    }
}

We’re down to the BlogWorkflow and Program.cs. Let’s start with the former. BlogWorkflow switches to RunStreamingAsync and WatchStreamAsync to stream the executor lifecycle events, while maintaining the topology:

using Microsoft.Agents.AI.Workflows;

namespace BlogMigration;

/// <summary>
/// Blog creation workflow built on the Microsoft Agent Framework workflow engine.
///
/// Topology (faithful to the original LangGraph StateGraph):
///   Blogger → Researcher → Author → Reviewer
///   Reviewer ⇄ Author  (bounded revision loop)
///   Reviewer → Output  (on approval or revision cap)
///
/// The revision loop is bounded by <see cref="ResearchState.MaxRevisions"/>: the
/// loop-back edge only fires while the draft is unapproved AND the revision count
/// is below the cap, so the workflow is guaranteed to terminate even if the
/// reviewer never returns "APPROVED".
/// </summary>
public class BlogWorkflow(
    IBloggerChain blogger,
    IResearcherAgent researcher,
    IAuthorChain author,
    IReviewerChain reviewer) : IBlogWorkflow
{
    public async Task<ResearchState> RunAsync(ResearchState state)
    {
        var bloggerExecutor = new BloggerExecutor(blogger);
        var researcherExecutor = new ResearcherExecutor(researcher);
        var authorExecutor = new AuthorExecutor(author);
        var reviewerExecutor = new ReviewerExecutor(reviewer);

        Workflow workflow = new WorkflowBuilder(bloggerExecutor)
            .AddEdge(bloggerExecutor, researcherExecutor)
            .AddEdge(researcherExecutor, authorExecutor)
            .AddEdge(authorExecutor, reviewerExecutor)
            // Bounded revision loop: route back to the author only while the draft
            // still needs work and the revision cap has not been reached. When the
            // condition is false the reviewer instead yields the final output.
            .AddEdge<ResearchState>(reviewerExecutor, authorExecutor, condition: s => s?.NeedsRevision == true)
            .WithOutputFrom(reviewerExecutor)
            .Build();

        // Stream execution instead of running to completion in one shot. The
        // topology is identical to before (proven terminating, MAF-Doctor grade A);
        // streaming simply surfaces each executor's lifecycle as it happens, giving
        // live progress and replacing the scattered Console.WriteLine tracing that
        // previously lived inside the node classes. The final ResearchState is
        // captured from the WorkflowOutputEvent emitted by the reviewer.
        StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, state);

        ResearchState? result = null;

        await foreach (WorkflowEvent evt in run.WatchStreamAsync())
        {
            switch (evt)
            {
                case ExecutorInvokedEvent invoked:
                    Console.WriteLine($"[workflow] → {invoked.ExecutorId} started");
                    break;

                case ExecutorCompletedEvent completed:
                    Console.WriteLine($"[workflow] ✓ {completed.ExecutorId} completed");
                    break;

                case ExecutorFailedEvent failed:
                    Console.WriteLine($"[workflow] ✗ {failed.ExecutorId} failed: {(failed.Data as Exception)?.Message}");
                    break;

                case WorkflowOutputEvent { Data: ResearchState finalState }:
                    // The reviewer yielded the final, approved (or revision-capped) state.
                    result = finalState;
                    break;
            }
        }

        // Fall back to the input state only if no output event was ever produced.
        return result ?? state;
    }
}

Finally, we’re ready to update Program.cs. Here we add UseFunctionInvocation to the IChatClient pipeline which is required for the researcher’s tool calls to execute:

using System.ClientModel;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using BlogMigration;
using Microsoft.Extensions.AI;
using OpenAI;

const string fileName = "config.json";

using var stream = File.OpenRead(fileName);
using var document = JsonDocument.Parse(stream);
JsonElement config = document.RootElement;

string? GetValue(string key) =>
    config.TryGetProperty(key, out JsonElement value) ? value.GetString() : null;

Environment.SetEnvironmentVariable("OPENAI_API_KEY", GetValue("API_KEY"));
Environment.SetEnvironmentVariable("OPENAI_BASE_URL", GetValue("OPENAI_API_BASE"));
Environment.SetEnvironmentVariable("TAVILY_API_KEY", GetValue("TAVILY_API_KEY"));

string modelName = "gpt-4o-mini";

var openAIClient = new OpenAIClient(
    new ApiKeyCredential(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!),
    new OpenAIClientOptions
    {
        Endpoint = new Uri(Environment.GetEnvironmentVariable("OPENAI_BASE_URL")!)
    });

// Build the IChatClient pipeline once and share it across all agents.
// UseFunctionInvocation() adds the middleware that actually *executes* the tool
// calls the model requests — without it, attaching the Tavily tool to the
// Researcher agent would let the model ask for a search but nothing would run it.
// Middleware is applied inner-to-outer, so function invocation wraps the raw
// OpenAI client. (To add distributed tracing later, chain .UseOpenTelemetry()
// here and register the source with a TracerProvider.)
IChatClient llm = openAIClient
    .GetChatClient(modelName)
    .AsIChatClient()
    .AsBuilder()
    .UseFunctionInvocation()
    .Build();

var chatOptions = new ChatOptions
{
    Temperature = 0,
    MaxOutputTokens = 4096
};

var tavilyHttpClient = new HttpClient { BaseAddress = new Uri("https://api.tavily.com/") };
tavilyHttpClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("TAVILY_API_KEY"));

AIFunction tavilyTool = AIFunctionFactory.Create(
    async (string query) =>
    {
        var request = new
        {
            query,
            max_results = 5,
            topic = "general",
            include_answer = false,
            include_raw_content = false,
            search_depth = "basic"
        };

        using HttpResponseMessage response = await tavilyHttpClient.PostAsJsonAsync("search", request);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    },
    name: "tavily_search",
    description: "A search engine optimized for comprehensive, accurate, and trusted results.");

// Creating a callable object
var bloggerChain = new BloggerChain(llm, chatOptions);
var researcherAgent = new ResearcherAgent(llm, chatOptions, tavilyTool);
var authorChain = new AuthorChain(llm, chatOptions);
var reviewerChain = new ReviewerChain(llm, chatOptions);
var app = new BlogWorkflow(bloggerChain, researcherAgent, authorChain, reviewerChain);

// Run the workflow for a sample topic
var initialState = new ResearchState
{
    MainTask = "use of multiagents in writing a C# application"
};

ResearchState result = await app.RunAsync(initialState);

Console.WriteLine("\n========== RESULTS ==========");
Console.WriteLine($"Task: {result.MainTask}");

Console.WriteLine($"\nResearch Findings ({result.ResearchFindings.Count}):");
foreach (string finding in result.ResearchFindings)
{
    Console.WriteLine($"- {finding}");
}

Console.WriteLine($"\nDraft:\n{result.Draft}");
Console.WriteLine($"\nReview Notes: {result.ReviewNotes}");
Console.WriteLine($"Revision Number: {result.RevisionNumber}");
Console.WriteLine("=============================");

I’ll have a lot more to say about the Microsoft Agent Framework in coming blog posts, often pointing back to this code.

Unknown's avatar

About Jesse Liberty

** Note ** Jesse is currently looking for a new position. You can learn more about him at https://jesseliberty.bio Thank you. Jesse Liberty has three decades of experience writing and delivering software projects and is the author of 2 dozen books and a couple dozen online courses. His latest book, Building APIs with .NET, is now available wherever you buy your books. Liberty was a Team Lead and Senior Software Engineer for various corporations, a Senior Technical Evangelist for Microsoft, a Distinguished Software Engineer for AT&T, a VP for Information Services for Citibank and a Software Architect for PBS. He is a 13 year Microsoft MVP.
This entry was posted in AI, Microsoft Agent Framework, Programming. Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *