Migrating C# -> Microsoft Agent Framework

In the previous blog posts 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.

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. The LLM fallback now uses structured output and we’ve removed the “` fence stripping and the deserialize method.

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. BlowWorkflow 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 *