diff --git a/.gitignore b/.gitignore index e4be984..abaa4a7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ **/bin/ **/obj/ config.json +.env +.env.* +!.env.example .claude/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 7e5f235..5b1a61c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,3 +87,7 @@ If none of the above fits, call `MafTour()` for the full capability catalogue (every tool / prompt / resource / agent, one line each), or run the `maf-help` prompt for guided 3-question triage. + +## Microsoft Foundry hosted agents + +This project was built with the microsoft-foundry skill. Before working on or answering questions about Foundry agents, read the microsoft-foundry skill first. If you are in VS Code, read the vscode-microsoft-foundry skill first. diff --git a/AuthorAgent.cs b/AuthorAgent.cs index 8c27975..ee8b294 100644 --- a/AuthorAgent.cs +++ b/AuthorAgent.cs @@ -21,27 +21,11 @@ public class AuthorAgent : IAuthorAgent private readonly ILogger _logger; - // Per-call output-token cap, applied on each RunAsync to bound cost. - private readonly int? _maxOutputTokens; - - public AuthorAgent(IChatClient llm, ChatOptions chatOptions, ILogger logger) + public AuthorAgent(AIAgent agent, ILogger logger) { _logger = logger; - _maxOutputTokens = chatOptions.MaxOutputTokens; - _agent = new ChatClientAgent(llm, new ChatClientAgentOptions - { - Name = "Author", - ChatOptions = new ChatOptions - { - Instructions = Prompts.AuthorInstructions, - Temperature = chatOptions.Temperature, - MaxOutputTokens = chatOptions.MaxOutputTokens, - }, - }) - .AsBuilder() - .UseOpenTelemetry(sourceName: "BlogWriter.Agents") - .Build(); + _agent = agent; _logger.LogInformation("AuthorAgent initialized."); } @@ -69,12 +53,7 @@ public AuthorAgent(IChatClient llm, ChatOptions chatOptions, ILogger( () => client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi again")])); } + + [Fact] + public async Task SharedFactory_EnforcesOneBudgetAcrossClients() + { + Func factory = TokenCapChatClient.CreateSharedFactory(maxTotalTokens: 100); + using IChatClient firstClient = factory(new FakeChatClient(totalTokens: 60)); + using IChatClient secondClient = factory(new FakeChatClient(totalTokens: 60)); + + await firstClient.GetResponseAsync([new ChatMessage(ChatRole.User, "first agent")]); + + await Assert.ThrowsAsync( + () => secondClient.GetResponseAsync([new ChatMessage(ChatRole.User, "second agent")])); + } } diff --git a/BlogWriter.csproj b/BlogWriter.csproj index c1243c7..6e38fd8 100644 --- a/BlogWriter.csproj +++ b/BlogWriter.csproj @@ -11,8 +11,16 @@ - + + + + + + + + @@ -21,15 +29,12 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - - + - - diff --git a/BloggerAgent.cs b/BloggerAgent.cs index 1d362b5..3fde22c 100644 --- a/BloggerAgent.cs +++ b/BloggerAgent.cs @@ -30,28 +30,22 @@ public class BloggerAgent : IBloggerAgent private readonly ILogger _logger; - // Per-call output-token cap, applied on each RunAsync to bound cost. - private readonly int? _maxOutputTokens; - - public BloggerAgent(IChatClient llm, ChatOptions chatOptions, ILogger logger) + public BloggerAgent(AIAgent agent, ILogger logger) { _logger = logger; - _maxOutputTokens = chatOptions.MaxOutputTokens; - _agent = new ChatClientAgent(llm, new ChatClientAgentOptions + _agent = agent; + _logger.LogInformation("BloggerAgent initialized."); + } + + // Compatibility overload for callers that supply an in-process test client. + public BloggerAgent(IChatClient llm, ChatOptions chatOptions, ILogger logger) + : this(new ChatClientAgent(llm, new ChatClientAgentOptions { Name = "Blogger", - ChatOptions = new ChatOptions - { - Instructions = Prompts.BloggerInstructions, - Temperature = chatOptions.Temperature, - MaxOutputTokens = chatOptions.MaxOutputTokens, - }, - }) - .AsBuilder() - .UseOpenTelemetry(sourceName: "BlogWriter.Agents") - .Build(); - _logger.LogInformation("BloggerAgent initialized."); + ChatOptions = chatOptions, + }), logger) + { } public async Task InvokeAsync(ResearchState state, CancellationToken cancellationToken = default) @@ -117,13 +111,8 @@ public async Task InvokeAsync(ResearchState state, Cancellation try { - // Cap per-call output tokens so a single turn can't blow the cost budget. - ChatClientAgentRunOptions runOptions = new(new ChatOptions - { - MaxOutputTokens = _maxOutputTokens, - }); AgentResponse response = - await _agent.RunAsync(stateSummary, options: runOptions, serializerOptions: _jsonOptions, cancellationToken: cancellationToken); + await _agent.RunAsync(stateSummary, serializerOptions: _jsonOptions, cancellationToken: cancellationToken); BloggerDecision decision = response.Result; if (decision is not null && !string.IsNullOrEmpty(decision.NextStep)) diff --git a/HostedAgents/Author/.agent_configs/baseline/instructions.md b/HostedAgents/Author/.agent_configs/baseline/instructions.md new file mode 100644 index 0000000..cdf8d84 --- /dev/null +++ b/HostedAgents/Author/.agent_configs/baseline/instructions.md @@ -0,0 +1 @@ +Drafts and revises complete professional blog posts from research findings and reviewer feedback. \ No newline at end of file diff --git a/HostedAgents/Author/.agent_configs/baseline/metadata.yaml b/HostedAgents/Author/.agent_configs/baseline/metadata.yaml new file mode 100644 index 0000000..1456307 --- /dev/null +++ b/HostedAgents/Author/.agent_configs/baseline/metadata.yaml @@ -0,0 +1 @@ +instruction_file: instructions.md diff --git a/HostedAgents/Author/.agentignore b/HostedAgents/Author/.agentignore new file mode 100644 index 0000000..5ea0cf9 --- /dev/null +++ b/HostedAgents/Author/.agentignore @@ -0,0 +1,43 @@ +# Files excluded from agent code deployment packaging. +# Uses .gitignore syntax. +# Note: only the root .agentignore is read; subdirectory files are not supported. +# +# To include a file that is excluded by default, use negation: !filename + +# azd tooling files +agent.yaml +agent.manifest.yaml +azure.yaml +.agentignore +appPackage.zip +.appPackage.zip.azd-generated +TEAMS_APP_SETUP.md + +# Security / secrets +.env +.env.* +.azure/ +.git/ + +# Python +__pycache__/ +.venv/ +venv/ +*.pyc +*.pyo +.mypy_cache/ +.pytest_cache/ + +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +# Node +node_modules/ + +# Docker (not used in code deploy) +Dockerfile +.dockerignore diff --git a/HostedAgents/Author/AgentPrompt.cs b/HostedAgents/Author/AgentPrompt.cs new file mode 100644 index 0000000..35662c4 --- /dev/null +++ b/HostedAgents/Author/AgentPrompt.cs @@ -0,0 +1,19 @@ +namespace BlogWriter; + +public static class Prompts +{ + public const string AuthorInstructions = """ +You are a professional blogger. + +The user message contains the main task, the research findings, the current +draft (if any), any reviewer notes, and the target word count range. + +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 +- Aim for the target word count range given in the user message. + +Write the complete post. +"""; +} diff --git a/HostedAgents/Author/Author.HostedAgent.csproj b/HostedAgents/Author/Author.HostedAgent.csproj new file mode 100644 index 0000000..8166305 --- /dev/null +++ b/HostedAgents/Author/Author.HostedAgent.csproj @@ -0,0 +1,19 @@ + + + + Exe + BlogWriter.HostedAgents.Author + BlogWriter.HostedAgents.Author + net10.0 + enable + enable + 1288dcff-4dfa-44b7-a6d3-30ff6c988520 + + + + + + + + + diff --git a/HostedAgents/Author/Program.cs b/HostedAgents/Author/Program.cs new file mode 100644 index 0000000..6a75a93 --- /dev/null +++ b/HostedAgents/Author/Program.cs @@ -0,0 +1,28 @@ +using Azure.AI.AgentServer.Core; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; + +// Foundry Hosted Agent for the "Author" role. Deployed independently +// (azd ai agent init / azd provision / azd deploy — see README) and referenced +// by name from the BlogWriter console app. + +var projectEndpoint = new Uri( + Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); +string modelDeployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5-mini"; + +// Entra ID only — no API keys, per repository constraint. +AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) + .AsAIAgent( + model: modelDeployment, + instructions: BlogWriter.Prompts.AuthorInstructions, + name: "Author"); + +var builder = AgentHost.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses()); + +var app = builder.Build(); +app.Run(); diff --git a/HostedAgents/Author/README.md b/HostedAgents/Author/README.md new file mode 100644 index 0000000..48acd33 --- /dev/null +++ b/HostedAgents/Author/README.md @@ -0,0 +1,16 @@ +# Author — Foundry Hosted Agent + +Hosts the Author role (drafts/revises the post) as an Azure AI Foundry Hosted +Agent. See `../README.md` for the shared `azd` deploy flow. + +## Configuration + +Set before `azd ai agent run` / `azd deploy` (env vars, or via `azd env set`): + +| Key | Required | Default | +| --- | --- | --- | +| `FOUNDRY_PROJECT_ENDPOINT` | yes | — | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` | + +Instructions are compiled from the deployment-local `AgentPrompt.cs`. Keep it +aligned with `../../Prompts.cs` when changing the Author prompt. diff --git a/HostedAgents/Author/azure.yaml b/HostedAgents/Author/azure.yaml new file mode 100644 index 0000000..9eb2ef6 --- /dev/null +++ b/HostedAgents/Author/azure.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: blogwriter-hosted-agent-author +metadata: + template: blogwriter-hosted-agent +services: + ai-project: + host: azure.ai.project + blogwriter-author: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + description: Drafts and revises blog posts. + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Author.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi + kind: hosted + name: blogwriter-author + environmentVariables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + protocols: + - protocol: responses + version: 2.0.0 diff --git a/HostedAgents/Author/eval.yaml b/HostedAgents/Author/eval.yaml new file mode 100644 index 0000000..8c9ad74 --- /dev/null +++ b/HostedAgents/Author/eval.yaml @@ -0,0 +1,8 @@ +name: smoke-core +agent: + name: blogwriter-author + kind: hosted + config: .agent_configs\baseline\metadata.yaml +options: + eval_model: gpt-5-mini +max_samples: 15 diff --git a/HostedAgents/Blogger/.agent_configs/baseline/instructions.md b/HostedAgents/Blogger/.agent_configs/baseline/instructions.md new file mode 100644 index 0000000..8c32ade --- /dev/null +++ b/HostedAgents/Blogger/.agent_configs/baseline/instructions.md @@ -0,0 +1 @@ +Routes the BlogWriter workflow to the next best step based on current research, draft, review status, and revision count. \ No newline at end of file diff --git a/HostedAgents/Blogger/.agent_configs/baseline/metadata.yaml b/HostedAgents/Blogger/.agent_configs/baseline/metadata.yaml new file mode 100644 index 0000000..1456307 --- /dev/null +++ b/HostedAgents/Blogger/.agent_configs/baseline/metadata.yaml @@ -0,0 +1 @@ +instruction_file: instructions.md diff --git a/HostedAgents/Blogger/.agentignore b/HostedAgents/Blogger/.agentignore new file mode 100644 index 0000000..5ea0cf9 --- /dev/null +++ b/HostedAgents/Blogger/.agentignore @@ -0,0 +1,43 @@ +# Files excluded from agent code deployment packaging. +# Uses .gitignore syntax. +# Note: only the root .agentignore is read; subdirectory files are not supported. +# +# To include a file that is excluded by default, use negation: !filename + +# azd tooling files +agent.yaml +agent.manifest.yaml +azure.yaml +.agentignore +appPackage.zip +.appPackage.zip.azd-generated +TEAMS_APP_SETUP.md + +# Security / secrets +.env +.env.* +.azure/ +.git/ + +# Python +__pycache__/ +.venv/ +venv/ +*.pyc +*.pyo +.mypy_cache/ +.pytest_cache/ + +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +# Node +node_modules/ + +# Docker (not used in code deploy) +Dockerfile +.dockerignore diff --git a/HostedAgents/Blogger/.gitignore b/HostedAgents/Blogger/.gitignore new file mode 100644 index 0000000..8e84380 --- /dev/null +++ b/HostedAgents/Blogger/.gitignore @@ -0,0 +1 @@ +.azure diff --git a/HostedAgents/Blogger/AgentPrompt.cs b/HostedAgents/Blogger/AgentPrompt.cs new file mode 100644 index 0000000..bb769b7 --- /dev/null +++ b/HostedAgents/Blogger/AgentPrompt.cs @@ -0,0 +1,21 @@ +namespace BlogWriter; + +public static class Prompts +{ + 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 >= 2, choose "END" + +Return the next step and a brief task description. +"""; +} diff --git a/HostedAgents/Blogger/Blogger.HostedAgent.csproj b/HostedAgents/Blogger/Blogger.HostedAgent.csproj new file mode 100644 index 0000000..e7c828a --- /dev/null +++ b/HostedAgents/Blogger/Blogger.HostedAgent.csproj @@ -0,0 +1,19 @@ + + + + Exe + BlogWriter.HostedAgents.Blogger + BlogWriter.HostedAgents.Blogger + net10.0 + enable + enable + c8b24fdb-2d17-44b8-827b-da514a8a9316 + + + + + + + + + diff --git a/HostedAgents/Blogger/Program.cs b/HostedAgents/Blogger/Program.cs new file mode 100644 index 0000000..69b4206 --- /dev/null +++ b/HostedAgents/Blogger/Program.cs @@ -0,0 +1,29 @@ +using Azure.AI.AgentServer.Core; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; + +// Foundry Hosted Agent for the "Blogger" role. Deployed independently +// (azd ai agent init / azd provision / azd deploy — see README) and referenced +// by name from the BlogWriter console app; this project does not run as part +// of the console app's process. + +var projectEndpoint = new Uri( + Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); +string modelDeployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5-mini"; + +// Entra ID only — no API keys, per repository constraint. +AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) + .AsAIAgent( + model: modelDeployment, + instructions: BlogWriter.Prompts.BloggerInstructions, + name: "Blogger"); + +var builder = AgentHost.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses()); + +var app = builder.Build(); +app.Run(); diff --git a/HostedAgents/Blogger/README.md b/HostedAgents/Blogger/README.md new file mode 100644 index 0000000..b4a15f5 --- /dev/null +++ b/HostedAgents/Blogger/README.md @@ -0,0 +1,16 @@ +# Blogger — Foundry Hosted Agent + +Hosts the Blogger role (workflow routing decisions) as an Azure AI Foundry +Hosted Agent. See `../README.md` for the shared `azd` deploy flow. + +## Configuration + +Set before `azd ai agent run` / `azd deploy` (env vars, or via `azd env set`): + +| Key | Required | Default | +| --- | --- | --- | +| `FOUNDRY_PROJECT_ENDPOINT` | yes | — | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` | + +Instructions are compiled from the deployment-local `AgentPrompt.cs`. Keep it +aligned with `../../Prompts.cs` when changing the Blogger prompt. diff --git a/HostedAgents/Blogger/azure.yaml b/HostedAgents/Blogger/azure.yaml new file mode 100644 index 0000000..c8b0d13 --- /dev/null +++ b/HostedAgents/Blogger/azure.yaml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: blogger +services: + ai-project: + host: azure.ai.project + blogwriter-blogger: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + description: Routes the BlogWriter workflow to the next best step. + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Blogger.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi + kind: hosted + name: blogwriter-blogger + environmentVariables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + protocols: + - protocol: responses + version: 2.0.0 diff --git a/HostedAgents/Blogger/eval.yaml b/HostedAgents/Blogger/eval.yaml new file mode 100644 index 0000000..ceb2180 --- /dev/null +++ b/HostedAgents/Blogger/eval.yaml @@ -0,0 +1,8 @@ +name: smoke-core +agent: + name: blogwriter-blogger + kind: hosted + config: .agent_configs\baseline\metadata.yaml +options: + eval_model: gpt-5-mini +max_samples: 15 diff --git a/HostedAgents/README.md b/HostedAgents/README.md new file mode 100644 index 0000000..cd482dc --- /dev/null +++ b/HostedAgents/README.md @@ -0,0 +1,54 @@ +# HostedAgents + +Each subfolder here is an independent **Azure AI Foundry Hosted Agent** +(Foundry Agent Service) — a small `AgentHost` app built with +`Microsoft.Agents.AI.Foundry.Hosting` that wraps one of BlogWriter's 4 MAF +agents and exposes it over the OpenAI-compatible Responses protocol +(`/responses`). They are **deployed independently** from the main console +app (`BlogWriter/`), which only calls them by name over the network — it +never builds or provisions them at runtime. + +| Project | Role | Notes | +| --- | --- | --- | +| `Blogger/` | Orchestration decisions (next step routing) | | +| `Researcher/` | Web research | Owns a Foundry-hosted web-search tool | +| `Author/` | Drafts/revises the post | | +| `Reviewer/` | Approves or requests revisions | | + +## Prerequisites (once per machine) + +```powershell +azd ext install microsoft.foundry +azd auth login +``` + +## Deploying a hosted agent + +From inside each `HostedAgents/` folder: + +```powershell +# First time only: scaffold azd wiring for this folder (or hand-author azure.yaml — see below) +azd ai agent init --deploy-mode code + +# Provision Foundry project/model/ACR resources (skip if reusing an existing project) +azd provision + +# Test locally before shipping +azd ai agent run +azd ai agent invoke "Hello!" + +# Deploy the source to Foundry Agent Service +azd deploy + +# Invoke / monitor the deployed agent +azd ai agent invoke "Hello!" +azd ai agent monitor --follow +``` + +Set `FOUNDRY_PROJECT_ENDPOINT` and `AZURE_AI_MODEL_DEPLOYMENT_NAME` before +`azd ai agent run` / `azd deploy` — see each project's own README. + +> **Note:** `azure.yaml` in each folder is a starting-point manifest, not a +> generated one — regenerate/replace it via `azd ai agent init` against your +> real Foundry project before deploying for real. `Microsoft.Agents.AI.Foundry.Hosting` +> is still a **prerelease** package; re-validate versions before production use. diff --git a/HostedAgents/Researcher/.agent_configs/baseline/instructions.md b/HostedAgents/Researcher/.agent_configs/baseline/instructions.md new file mode 100644 index 0000000..fc9c766 --- /dev/null +++ b/HostedAgents/Researcher/.agent_configs/baseline/instructions.md @@ -0,0 +1 @@ +Performs current web research for technical .NET and AI blog posts using Foundry-hosted web search, then summarizes credible findings. \ No newline at end of file diff --git a/HostedAgents/Researcher/.agent_configs/baseline/metadata.yaml b/HostedAgents/Researcher/.agent_configs/baseline/metadata.yaml new file mode 100644 index 0000000..1456307 --- /dev/null +++ b/HostedAgents/Researcher/.agent_configs/baseline/metadata.yaml @@ -0,0 +1 @@ +instruction_file: instructions.md diff --git a/HostedAgents/Researcher/.agentignore b/HostedAgents/Researcher/.agentignore new file mode 100644 index 0000000..5ea0cf9 --- /dev/null +++ b/HostedAgents/Researcher/.agentignore @@ -0,0 +1,43 @@ +# Files excluded from agent code deployment packaging. +# Uses .gitignore syntax. +# Note: only the root .agentignore is read; subdirectory files are not supported. +# +# To include a file that is excluded by default, use negation: !filename + +# azd tooling files +agent.yaml +agent.manifest.yaml +azure.yaml +.agentignore +appPackage.zip +.appPackage.zip.azd-generated +TEAMS_APP_SETUP.md + +# Security / secrets +.env +.env.* +.azure/ +.git/ + +# Python +__pycache__/ +.venv/ +venv/ +*.pyc +*.pyo +.mypy_cache/ +.pytest_cache/ + +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +# Node +node_modules/ + +# Docker (not used in code deploy) +Dockerfile +.dockerignore diff --git a/HostedAgents/Researcher/AgentPrompt.cs b/HostedAgents/Researcher/AgentPrompt.cs new file mode 100644 index 0000000..5fa5d3d --- /dev/null +++ b/HostedAgents/Researcher/AgentPrompt.cs @@ -0,0 +1,19 @@ +namespace BlogWriter; + +public static class Prompts +{ + 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. +"""; +} diff --git a/HostedAgents/Researcher/Program.cs b/HostedAgents/Researcher/Program.cs new file mode 100644 index 0000000..78bf225 --- /dev/null +++ b/HostedAgents/Researcher/Program.cs @@ -0,0 +1,34 @@ +using Azure.AI.AgentServer.Core; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; + +// Foundry Hosted Agent for the "Researcher" role. Deployed independently +// (azd ai agent init / azd provision / azd deploy — see README) and referenced +// by name from the BlogWriter console app. +// +// Unlike the other three hosted agents, this one owns a Foundry-hosted web +// search tool. Search executes inside the hosted process, not in the console. + +var projectEndpoint = new Uri( + Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); +string modelDeployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5-mini"; + +// Entra ID only — no API keys, per repository constraint (this applies to the +// Foundry model and hosted web-search authentication). +AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) + .AsAIAgent( + model: modelDeployment, + instructions: BlogWriter.Prompts.ResearcherInstructions, + name: "Researcher", + tools: [new HostedWebSearchTool()]); + +var builder = AgentHost.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses()); + +var app = builder.Build(); +app.Run(); diff --git a/HostedAgents/Researcher/README.md b/HostedAgents/Researcher/README.md new file mode 100644 index 0000000..b98fcfb --- /dev/null +++ b/HostedAgents/Researcher/README.md @@ -0,0 +1,18 @@ +# Researcher — Foundry Hosted Agent + +Hosts the Researcher role as an Azure AI Foundry Hosted Agent. See +`../README.md` for the shared `azd` deploy flow. + +Unlike the other 3 hosted agents, this one owns a Foundry-hosted web-search +tool. Search runs inside this hosted process, not in the console app. + +## Configuration + +Set before `azd ai agent run` / `azd deploy`: + +| Key | Required | Default | Notes | +| --- | --- | --- | --- | +| `FOUNDRY_PROJECT_ENDPOINT` | yes | — | Entra ID auth (`DefaultAzureCredential`) | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` | | +Instructions are compiled from the deployment-local `AgentPrompt.cs`. Keep it +aligned with `../../Prompts.cs` when changing the Researcher prompt. diff --git a/HostedAgents/Researcher/Researcher.HostedAgent.csproj b/HostedAgents/Researcher/Researcher.HostedAgent.csproj new file mode 100644 index 0000000..efab9b0 --- /dev/null +++ b/HostedAgents/Researcher/Researcher.HostedAgent.csproj @@ -0,0 +1,19 @@ + + + + Exe + BlogWriter.HostedAgents.Researcher + BlogWriter.HostedAgents.Researcher + net10.0 + enable + enable + a3f0f2b0-9c7f-4c34-9e26-3c26d2a6f2b1 + + + + + + + + + diff --git a/HostedAgents/Researcher/azure.yaml b/HostedAgents/Researcher/azure.yaml new file mode 100644 index 0000000..055ee13 --- /dev/null +++ b/HostedAgents/Researcher/azure.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: blogwriter-hosted-agent-researcher +metadata: + template: blogwriter-hosted-agent +services: + ai-project: + host: azure.ai.project + blogwriter-researcher: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + description: Performs web research for blog posts. + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Researcher.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi + kind: hosted + name: blogwriter-researcher + environmentVariables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + protocols: + - protocol: responses + version: 2.0.0 diff --git a/HostedAgents/Researcher/eval.yaml b/HostedAgents/Researcher/eval.yaml new file mode 100644 index 0000000..33fafd3 --- /dev/null +++ b/HostedAgents/Researcher/eval.yaml @@ -0,0 +1,8 @@ +name: smoke-core +agent: + name: blogwriter-researcher + kind: hosted + config: .agent_configs\baseline\metadata.yaml +options: + eval_model: gpt-5-mini +max_samples: 15 diff --git a/HostedAgents/Reviewer/.agent_configs/baseline/instructions.md b/HostedAgents/Reviewer/.agent_configs/baseline/instructions.md new file mode 100644 index 0000000..a548d1b --- /dev/null +++ b/HostedAgents/Reviewer/.agent_configs/baseline/instructions.md @@ -0,0 +1 @@ +Reviews blog drafts for hook, clarity, value, structure, tone, and target length, then approves or provides actionable revision feedback. \ No newline at end of file diff --git a/HostedAgents/Reviewer/.agent_configs/baseline/metadata.yaml b/HostedAgents/Reviewer/.agent_configs/baseline/metadata.yaml new file mode 100644 index 0000000..1456307 --- /dev/null +++ b/HostedAgents/Reviewer/.agent_configs/baseline/metadata.yaml @@ -0,0 +1 @@ +instruction_file: instructions.md diff --git a/HostedAgents/Reviewer/.agentignore b/HostedAgents/Reviewer/.agentignore new file mode 100644 index 0000000..5ea0cf9 --- /dev/null +++ b/HostedAgents/Reviewer/.agentignore @@ -0,0 +1,43 @@ +# Files excluded from agent code deployment packaging. +# Uses .gitignore syntax. +# Note: only the root .agentignore is read; subdirectory files are not supported. +# +# To include a file that is excluded by default, use negation: !filename + +# azd tooling files +agent.yaml +agent.manifest.yaml +azure.yaml +.agentignore +appPackage.zip +.appPackage.zip.azd-generated +TEAMS_APP_SETUP.md + +# Security / secrets +.env +.env.* +.azure/ +.git/ + +# Python +__pycache__/ +.venv/ +venv/ +*.pyc +*.pyo +.mypy_cache/ +.pytest_cache/ + +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +# Node +node_modules/ + +# Docker (not used in code deploy) +Dockerfile +.dockerignore diff --git a/HostedAgents/Reviewer/AgentPrompt.cs b/HostedAgents/Reviewer/AgentPrompt.cs new file mode 100644 index 0000000..4528a10 --- /dev/null +++ b/HostedAgents/Reviewer/AgentPrompt.cs @@ -0,0 +1,24 @@ +namespace BlogWriter; + +public static class Prompts +{ + public const string ReviewerInstructions = """ +You are a reviewer evaluating content for a blog post. + +The user message contains the main task, the target word count range, 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? +6. Size – Is the post within the target word count range given in the user message? + + +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 +"""; +} diff --git a/HostedAgents/Reviewer/Program.cs b/HostedAgents/Reviewer/Program.cs new file mode 100644 index 0000000..7b1e2d5 --- /dev/null +++ b/HostedAgents/Reviewer/Program.cs @@ -0,0 +1,28 @@ +using Azure.AI.AgentServer.Core; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; + +// Foundry Hosted Agent for the "Reviewer" role. Deployed independently +// (azd ai agent init / azd provision / azd deploy — see README) and referenced +// by name from the BlogWriter console app. + +var projectEndpoint = new Uri( + Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); +string modelDeployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5-mini"; + +// Entra ID only — no API keys, per repository constraint. +AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) + .AsAIAgent( + model: modelDeployment, + instructions: BlogWriter.Prompts.ReviewerInstructions, + name: "Reviewer"); + +var builder = AgentHost.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses()); + +var app = builder.Build(); +app.Run(); diff --git a/HostedAgents/Reviewer/README.md b/HostedAgents/Reviewer/README.md new file mode 100644 index 0000000..43dc2d8 --- /dev/null +++ b/HostedAgents/Reviewer/README.md @@ -0,0 +1,16 @@ +# Reviewer — Foundry Hosted Agent + +Hosts the Reviewer role (approves or requests revisions) as an Azure AI +Foundry Hosted Agent. See `../README.md` for the shared `azd` deploy flow. + +## Configuration + +Set before `azd ai agent run` / `azd deploy` (env vars, or via `azd env set`): + +| Key | Required | Default | +| --- | --- | --- | +| `FOUNDRY_PROJECT_ENDPOINT` | yes | — | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` | + +Instructions are compiled from the deployment-local `AgentPrompt.cs`. Keep it +aligned with `../../Prompts.cs` when changing the Reviewer prompt. diff --git a/HostedAgents/Reviewer/Reviewer.HostedAgent.csproj b/HostedAgents/Reviewer/Reviewer.HostedAgent.csproj new file mode 100644 index 0000000..2a6b9b3 --- /dev/null +++ b/HostedAgents/Reviewer/Reviewer.HostedAgent.csproj @@ -0,0 +1,19 @@ + + + + Exe + BlogWriter.HostedAgents.Reviewer + BlogWriter.HostedAgents.Reviewer + net10.0 + enable + enable + de40287b-1f47-4dd0-be3f-989a3ba5c989 + + + + + + + + + diff --git a/HostedAgents/Reviewer/azure.yaml b/HostedAgents/Reviewer/azure.yaml new file mode 100644 index 0000000..8fab74c --- /dev/null +++ b/HostedAgents/Reviewer/azure.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: blogwriter-hosted-agent-reviewer +metadata: + template: blogwriter-hosted-agent +services: + ai-project: + host: azure.ai.project + blogwriter-reviewer: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + description: Reviews blog drafts and approves or requests revisions. + codeConfiguration: + dependencyResolution: remote_build + entryPoint: BlogWriter.HostedAgents.Reviewer.dll + runtime: dotnet_10 + container: + resources: + cpu: "0.5" + memory: 1Gi + kind: hosted + name: blogwriter-reviewer + environmentVariables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + protocols: + - protocol: responses + version: 2.0.0 diff --git a/HostedAgents/Reviewer/eval.yaml b/HostedAgents/Reviewer/eval.yaml new file mode 100644 index 0000000..c7f08c7 --- /dev/null +++ b/HostedAgents/Reviewer/eval.yaml @@ -0,0 +1,8 @@ +name: smoke-core +agent: + name: blogwriter-reviewer + kind: hosted + config: .agent_configs\baseline\metadata.yaml +options: + eval_model: gpt-5-mini +max_samples: 15 diff --git a/Program.cs b/Program.cs index 135193c..2c53e93 100644 --- a/Program.cs +++ b/Program.cs @@ -1,13 +1,11 @@ -using System.ClientModel; -using System.Diagnostics; -using System.Net.Http.Headers; -using System.Net.Http.Json; +using System.Diagnostics; +using Azure.AI.Projects; +using Azure.Identity; using BlogWriter; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Client; -using OpenAI; // Secrets come from the .NET user-secrets store and from // environment variables (secrets win on key collisions). @@ -20,131 +18,49 @@ string GetRequired(string key) => config[key] ?? throw new InvalidOperationException( $"Missing configuration value '{key}'. Set it with: dotnet user-secrets set \"{key}\" \"\""); -string openAiApiKey = GetRequired("API_KEY"); -string openAiApiBase = GetRequired("OPENAI_BASE_URL"); -string tavilyApiKey = GetRequired("TAVILY_API_KEY"); - -// Overridable via user-secrets/env vars; these defaults match the original behaviour. -string modelName = config["MODEL_NAME"] ?? "gpt-5-mini"; -int maxOutputTokens = int.TryParse(config["MAX_OUTPUT_TOKENS"], out int configuredMaxOutputTokens) ? configuredMaxOutputTokens : 4096; +// Foundry project + hosted agent names — the 4 agents are pre-provisioned and +// deployed independently (see HostedAgents/*/README and azd scaffolding); +// this app only references them by name, it never creates/updates them. +var foundryProjectEndpoint = new Uri(GetRequired("FOUNDRY_PROJECT_ENDPOINT")); +string tenantId = GetRequired("AZURE_TENANT_ID"); +string bloggerAgentName = config["BLOGGER_AGENT_NAME"] ?? "Blogger"; +string researcherAgentName = config["RESEARCHER_AGENT_NAME"] ?? "Researcher"; +string authorAgentName = config["AUTHOR_AGENT_NAME"] ?? "Author"; +string reviewerAgentName = config["REVIEWER_AGENT_NAME"] ?? "Reviewer"; + +// Cumulative process-wide budget shared by all four MAF-hosted agent clients. long maxTotalTokens = long.TryParse(config["MAX_TOTAL_TOKENS"], out long configuredMaxTotalTokens) ? configuredMaxTotalTokens : 40000; -if (!Uri.TryCreate(openAiApiBase, UriKind.Absolute, out var uri)) -{ - throw new InvalidOperationException($"Invalid URI: '{openAiApiBase}'"); -} -var openAIClient = new OpenAIClient( - new ApiKeyCredential(openAiApiKey), - new OpenAIClientOptions - { - Endpoint = new Uri(openAiApiBase), - // The SDK's default RetryPolicy still applies on top of this; this only - // bounds how long a single network attempt can hang before it retries/fails. - NetworkTimeout = TimeSpan.FromSeconds(60), - }); -// 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. -// -// UseOpenTelemetry() emits a GenAI span per model round-trip (model name, token -// usage, tool calls). Its source is named "BlogWriter.ChatClient" so the -// ActivityListener registered below (which listens to every "BlogWriter.*" -// source) captures it alongside the agent/workflow spans — no TracerProvider -// or extra packages required. -// -// TokenCapChatClient is registered *after* function invocation, which makes it -// the innermost wrapper around the raw client — so it observes every individual -// model round-trip (including the extra calls tool invocation triggers) and -// enforces a hard cumulative-token budget for the whole process. -TokenCapChatClient? tokenCapChatClient = null; -IChatClient llm = openAIClient - .GetChatClient(modelName) - .AsIChatClient() - .AsBuilder() - .UseFunctionInvocation() - .UseOpenTelemetry(sourceName: "BlogWriter.ChatClient") - .Use(inner => tokenCapChatClient = new TokenCapChatClient(inner, maxTotalTokens)) - .Build(); - -var chatOptions = new ChatOptions +// Entra ID only — no API keys, per repository constraint. Agent Framework owns +// the Foundry transport and Responses protocol details. +var azureCredential = new AzureCliCredential(new AzureCliCredentialOptions { - Temperature = 1, - MaxOutputTokens = maxOutputTokens -}; - -var tavilyHttpClient = new HttpClient { BaseAddress = new Uri("https://api.tavily.com/"), Timeout = TimeSpan.FromSeconds(20) }; -tavilyHttpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", tavilyApiKey); - -// Small manual retry: transient network errors/timeouts get up to 2 retries -// with exponential backoff before the failure surfaces to the calling agent. -async Task PostWithRetryAsync(string requestUri, object body, CancellationToken cancellationToken) + TenantId = tenantId, +}); +AIProjectClient projectClient = new(foundryProjectEndpoint, azureCredential); +Func tokenCapFactory = TokenCapChatClient.CreateSharedFactory(maxTotalTokens); +AIAgent BuildFoundryAgent(string hostedAgentName) { - const int maxAttempts = 3; - for (int attempt = 1; ; attempt++) - { - try - { - HttpResponseMessage response = await tavilyHttpClient.PostAsJsonAsync(requestUri, body, cancellationToken); - response.EnsureSuccessStatusCode(); - return response; - } - catch (Exception ex) when (attempt < maxAttempts && ex is HttpRequestException or TaskCanceledException) - { - await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken); - } - } + Uri agentEndpoint = new($"{foundryProjectEndpoint.AbsoluteUri.TrimEnd('/')}/agents/{hostedAgentName}/endpoint/protocols/openai"); + return projectClient.AsAIAgent( + agentEndpoint, + tools: null, + clientFactory: tokenCapFactory, + services: null); } -AIFunction tavilyTool = AIFunctionFactory.Create( - async (string query, CancellationToken cancellationToken) => - { - var request = new - { - query, - max_results = 5, - topic = "general", - include_answer = false, - include_raw_content = false, - search_depth = "basic" - }; - - using HttpResponseMessage response = await PostWithRetryAsync("search", request, cancellationToken); - return await response.Content.ReadAsStringAsync(cancellationToken); - }, - name: "tavily_search", - description: "A search engine optimized for comprehensive, accurate, and trusted results."); +AIAgent bloggerLlm = BuildFoundryAgent(bloggerAgentName); +AIAgent researcherLlm = BuildFoundryAgent(researcherAgentName); +AIAgent authorLlm = BuildFoundryAgent(authorAgentName); +AIAgent reviewerLlm = BuildFoundryAgent(reviewerAgentName); // Creating a callable object using ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); -ILogger startupLogger = loggerFactory.CreateLogger("BlogWriter.Startup"); - -// Microsoft Learn's remote MCP server exposes docs search/fetch tools the -// Researcher can call alongside Tavily for authoritative Microsoft/Azure content. -// If the remote endpoint is unreachable/slow/erroring at startup, don't let it -// take down the whole app — fall back to Tavily-only tools. -List researcherTools = [tavilyTool]; -try -{ - McpClient microsoftLearnMcp = await McpClient.CreateAsync( - new HttpClientTransport(new HttpClientTransportOptions - { - Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), - Name = "microsoft-learn", - })); - IList microsoftLearnTools = await microsoftLearnMcp.ListToolsAsync(); - researcherTools.AddRange(microsoftLearnTools); -} -catch (Exception ex) -{ - startupLogger.LogWarning(ex, "Microsoft Learn MCP server unavailable; continuing with Tavily-only research tools."); -} -var bloggerAgent = new BloggerAgent(llm, chatOptions, loggerFactory.CreateLogger()); -var researcherAgent = new ResearcherAgent(llm, chatOptions, researcherTools, loggerFactory.CreateLogger()); -var authorAgent = new AuthorAgent(llm, chatOptions, loggerFactory.CreateLogger()); -var reviewerAgent = new ReviewerAgent(llm, chatOptions, loggerFactory.CreateLogger()); +var bloggerAgent = new BloggerAgent(bloggerLlm, loggerFactory.CreateLogger()); +var researcherAgent = new ResearcherAgent(researcherLlm, loggerFactory.CreateLogger()); +var authorAgent = new AuthorAgent(authorLlm, loggerFactory.CreateLogger()); +var reviewerAgent = new ReviewerAgent(reviewerLlm, loggerFactory.CreateLogger()); var app = new BlogWorkflow(bloggerAgent, researcherAgent, authorAgent, reviewerAgent, loggerFactory.CreateLogger()); // Distributed tracing: an ActivityListener activates every "BlogWriter.*" @@ -246,25 +162,15 @@ int ReadWordCount(string prompt, int defaultValue, int? minimum = null) Console.WriteLine($"- {finding}"); } -Console.WriteLine($"\n\n========== Draft ==========\n\n{result.Draft}"); -Console.WriteLine($"\n========== Review Notes ==========\n{result.ReviewNotes}"); -Console.WriteLine($"\n========== Revision Notes ==========\n{result.RevisionNumber}"); -if (result.RevisionNumber >= ResearchState.MaxRevisions) +Console.WriteLine($"\nDraft:\n{result.Draft}"); +Console.WriteLine($"\nReview Notes: {result.ReviewNotes}"); +Console.WriteLine($"Revision Number: {result.RevisionNumber}"); +if (result.RevisionLimitReached) { // The revision cap terminates the loop even if the reviewer never approved — // call that out so the draft above isn't mistaken for a reviewer-approved one. Console.WriteLine("Note: Maximum revision limit reached; draft above printed as-is."); } -Console.WriteLine("\n=============================\n"); +Console.WriteLine("============================="); -if (tokenCapChatClient is not null) -{ - TokenUsageSnapshot usage = tokenCapChatClient.UsageSnapshot; - Console.WriteLine("\n========== TOKEN USAGE =========="); - Console.WriteLine($"Input tokens: {usage.InputTokens}"); - Console.WriteLine($"Output tokens: {usage.OutputTokens}"); - Console.WriteLine($"Reasoning tokens: {usage.ReasoningTokens}"); - Console.WriteLine($"Total tokens: {usage.TotalTokens}"); - Console.WriteLine("=================================="); -} diff --git a/README.md b/README.md index baf8e5a..55ea906 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,61 @@ # Demo code associated with a [series of blog posts](https://jesseliberty.com) -This demonstration program, **Blog Writer**, is designed to research and write blog posts. It was written with *Microsoft Agent Framework* and the principal actors are the **BloggerAgent** which works as the orchestrator, the **ResearcherAgent** which goes out to the Web to research the requested topic, the **WriterAgent** which then writes the blog post, and the **ReviewerAgent** which reviews the proposed blog post, sending it back to the AuthorAgent if it is not approved. +This demonstration program, **Blog Writer**, is designed to research and write blog posts. It was written with *Microsoft Agent Framework* and the principal actors are the **BloggerAgent** which works as the orchestrator, the **ResearcherAgent** which goes out to the Web to research the requested topic, the **AuthorAgent** which then writes the blog post, and the **ReviewerAgent** which reviews the proposed blog post, sending it back to the AuthorAgent if it is not approved. The system prompts for each agent is contained in Prompts.cs BlogWorkflow is responsible for creating the nodes and edges for moving through the workflow and also contains the logic for managing a breach of the token-cap (the maximum number of tokens that can be used in a single request, as defined in TokenCapChatClient). +## Architecture: Azure AI Foundry Hosted Agents + +The 4 agents are deployed as independent **Azure AI Foundry Hosted Agents** +(Foundry Agent Service), each with its own managed compute, dedicated +Microsoft Entra ID identity, and OpenAI-compatible `/responses` endpoint. The +console app (this project) no longer builds the agents in-process — it only +**orchestrates** them locally via the MAF Workflow in `BlogWorkflow.cs`, +calling each hosted agent as a remote `IChatClient` +using the Microsoft Agent Framework Foundry integration. + +``` +BlogWriter/ (console app — orchestration only, calls hosted agents remotely) +HostedAgents/ + Blogger/ (Foundry Hosted Agent — orchestration decisions) + Researcher/ (Foundry Hosted Agent — owns hosted web search) + Author/ (Foundry Hosted Agent — drafts/revises the post) + Reviewer/ (Foundry Hosted Agent — approves or requests revisions) +``` + +Each `HostedAgents/` project is deployed independently via `azd` (see +its own README) and is **pre-provisioned** — the console app only references +already-deployed hosted agents by name, it never creates or updates them at +runtime. + +### Configuration (console app) + +Set via `dotnet user-secrets` (preferred for local dev) or environment +variables — Entra ID (`DefaultAzureCredential`) is used for all Foundry/model +auth, no API keys: + +| Key | Required | Default | Notes | +| --- | --- | --- | --- | +| `FOUNDRY_PROJECT_ENDPOINT` | yes | — | e.g. `https://.services.ai.azure.com/api/projects/` | +| `AZURE_TENANT_ID` | yes | — | Microsoft Entra tenant hosting the Foundry project | +| `BLOGGER_AGENT_NAME` | no | `Blogger` | Name of the deployed hosted agent | +| `RESEARCHER_AGENT_NAME` | no | `Researcher` | | +| `AUTHOR_AGENT_NAME` | no | `Author` | | +| `REVIEWER_AGENT_NAME` | no | `Reviewer` | | +| `MAX_TOTAL_TOKENS` | no | `40000` | Cumulative process-wide cap (`TokenCapChatClient`) | + +## Documentation +* [docs/architecture.md](docs/architecture.md) — full architecture, workflow graph, auth, and token-budget details. +* [docs/changelog-v1-to-v2.md](docs/changelog-v1-to-v2.md) — what changed from the original in-process design to the current hosted-agent one. +* [docs/deployment.md](docs/deployment.md) — the `azd` flow for deploying/redeploying each hosted agent and running the console app locally. +* [docs/configuration.md](docs/configuration.md) — every environment variable/secret used by the console app and the four hosted agents. + ## Miscellaneous Notes -* The program takes advantage of Tavily as a tool to search the Web. -* All configuration is managed by Microsoft Secrets. -* gpt-4o-mini is hard coded (for now) into the program. +* Web search runs **inside the hosted Researcher agent** through Foundry's hosted web-search tool. +* Foundry/model access uses Microsoft Entra ID exclusively; the console app authenticates with `DefaultAzureCredential`. +* The model deployment is chosen per hosted agent (via `AZURE_AI_MODEL_DEPLOYMENT_NAME` in each `HostedAgents/` project), not hardcoded in the console app. ## Additional Features * Middleware is used to manage the tools. @@ -20,4 +66,5 @@ BlogWorkflow is responsible for creating the nodes and edges for moving through We are seeing a lot of calls to the LLM. Either there is a problem with the calls or with the telemetry. ## Next Steps -Primary next step is to demonstrate the deployment of the application to Foundry. +* The `Microsoft.Agents.AI.Foundry.Hosting` package used by `HostedAgents/*` is still prerelease — re-validate before production use. +* Decide whether the Researcher's hosted agent should also expose the Responses+Invocations combo, or add more Foundry Toolbox tools (Code Interpreter, Azure AI Search) now that it's hosted. diff --git a/ResearchState.cs b/ResearchState.cs index 4de0afd..603e4ed 100644 --- a/ResearchState.cs +++ b/ResearchState.cs @@ -38,4 +38,7 @@ public static bool IsApproved(string? reviewNotes) => /// reached. Drives the bounded review loop; when false the workflow terminates. /// public bool NeedsRevision => !IsApproved(ReviewNotes) && RevisionNumber < MaxRevisions; + + /// True when review ended only because the revision cap was reached. + public bool RevisionLimitReached => !IsApproved(ReviewNotes) && RevisionNumber >= MaxRevisions; } diff --git a/ResearcherAgent.cs b/ResearcherAgent.cs index 0d809af..05f41ff 100644 --- a/ResearcherAgent.cs +++ b/ResearcherAgent.cs @@ -9,9 +9,11 @@ namespace BlogWriter; /// Performs research tasks with a and returns /// concise findings. /// -/// The agent can call the configured tools (e.g. Tavily web search, Microsoft -/// Learn MCP) during execution and summarize results for use in later drafting -/// stages. +/// The web-search tool runs inside the Researcher Foundry Hosted +/// Agent itself (see HostedAgents/Researcher), not in this process — the +/// passed in is a remote IChatClient talking to +/// that hosted agent's /responses endpoint, so tool calls happen +/// server-side and are already reflected in the returned text/usage. /// public class ResearcherAgent : IResearcherAgent { @@ -26,49 +28,13 @@ public class ResearcherAgent : IResearcherAgent private readonly ILogger _logger; - // Per-call output-token cap, applied on each RunAsync to bound cost. - private readonly int? _maxOutputTokens; - - public ResearcherAgent(IChatClient llm, ChatOptions chatOptions, IEnumerable tools, ILogger logger) + public ResearcherAgent(AIAgent agent, ILogger logger) { _logger = logger; - _maxOutputTokens = chatOptions.MaxOutputTokens; - - List toolList = [.. tools]; - - _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 tools lets the model call them autonomously. - Tools = toolList, - }, - }) - .AsBuilder() - // Function-invocation middleware: fires around every tool call the agent - // makes. We log each time the model invokes one of the attached tools. - .Use(async (agent, context, next, cancellationToken) => - { - _logger.LogInformation( - "Researcher invoking tool '{Tool}'...", - context.Function.Name); - return await next(context, cancellationToken); - }) - .UseOpenTelemetry(sourceName: "BlogWriter.Agents") - .Build(); + _agent = agent; - _logger.LogInformation( - "ResearcherAgent initialized with tools: {ToolNames}", - string.Join(", ", toolList.Select(t => t.Name))); + _logger.LogInformation("ResearcherAgent initialized (web search runs inside the hosted Researcher agent)."); } /// Execute research by letting the agent search and summarise. @@ -79,14 +45,9 @@ public async Task InvokeAsync(string query, CancellationToken cancellati try { - // A single agent run: the model may call tavily_search one or more + // A single agent run: the model may call hosted web search one or more // times, read the results, and return a concise summary as its text. - // Cap per-call output tokens so a single turn can't blow the cost budget. - ChatClientAgentRunOptions runOptions = new(new ChatOptions - { - MaxOutputTokens = _maxOutputTokens, - }); - AgentResponse response = await _agent.RunAsync(query, options: runOptions, cancellationToken: cancellationToken); + AgentResponse response = await _agent.RunAsync(query, cancellationToken: cancellationToken); string summary = response.Text; return !string.IsNullOrEmpty(summary) diff --git a/ReviewerAgent.cs b/ReviewerAgent.cs index 82edd1b..ab7fdc8 100644 --- a/ReviewerAgent.cs +++ b/ReviewerAgent.cs @@ -22,27 +22,11 @@ public class ReviewerAgent : IReviewerAgent private readonly ILogger _logger; - // Per-call output-token cap, applied on each RunAsync to bound cost. - private readonly int? _maxOutputTokens; - - public ReviewerAgent(IChatClient llm, ChatOptions chatOptions, ILogger logger) + public ReviewerAgent(AIAgent agent, ILogger logger) { _logger = logger; - _maxOutputTokens = chatOptions.MaxOutputTokens; - _agent = new ChatClientAgent(llm, new ChatClientAgentOptions - { - Name = "Reviewer", - ChatOptions = new ChatOptions - { - Instructions = Prompts.ReviewerInstructions, - Temperature = chatOptions.Temperature, - MaxOutputTokens = chatOptions.MaxOutputTokens, - }, - }) - .AsBuilder() - .UseOpenTelemetry(sourceName: "BlogWriter.Agents") - .Build(); + _agent = agent; _logger.LogInformation("ReviewerAgent initialized."); } @@ -65,12 +49,7 @@ public async Task InvokeAsync(ResearchState state, CancellationToken can try { - // Cap per-call output tokens so a single turn can't blow the cost budget. - ChatClientAgentRunOptions runOptions = new(new ChatOptions - { - MaxOutputTokens = _maxOutputTokens, - }); - AgentResponse response = await _agent.RunAsync(message, options: runOptions, cancellationToken: cancellationToken); + AgentResponse response = await _agent.RunAsync(message, cancellationToken: cancellationToken); string content = response.Text; return !string.IsNullOrEmpty(content) ? content : ManageError("No review content returned from the agent."); } diff --git a/TokenCapChatClient.cs b/TokenCapChatClient.cs index 245cbde..61cf363 100644 --- a/TokenCapChatClient.cs +++ b/TokenCapChatClient.cs @@ -16,25 +16,28 @@ public sealed class TokenCapChatClient : DelegatingChatClient // UsageDetails.AdditionalCounts (there is no dedicated top-level property). private const string ReasoningTokenCountKey = "OutputTokenDetails.ReasoningTokenCount"; - private readonly long _maxTotalTokens; - private long _totalTokens; - private long _inputTokens; - private long _outputTokens; - private long _reasoningTokens; + private readonly TokenBudget _budget; - public TokenCapChatClient(IChatClient innerClient, long maxTotalTokens) : base(innerClient) + public TokenCapChatClient(IChatClient innerClient, long maxTotalTokens) + : this(innerClient, new TokenBudget(maxTotalTokens)) { - _maxTotalTokens = maxTotalTokens > 0 - ? maxTotalTokens - : throw new ArgumentOutOfRangeException(nameof(maxTotalTokens), maxTotalTokens, "Token cap must be a positive number."); + } + + private TokenCapChatClient(IChatClient innerClient, TokenBudget budget) : base(innerClient) => + _budget = budget; + + /// + /// Creates a MAF chat-client middleware factory whose clients share one + /// cumulative process-wide token budget. + /// + public static Func CreateSharedFactory(long maxTotalTokens) + { + var budget = new TokenBudget(maxTotalTokens); + return innerClient => new TokenCapChatClient(innerClient, budget); } /// Cumulative token usage observed across every model round-trip so far. - public TokenUsageSnapshot UsageSnapshot => new( - Interlocked.Read(ref _inputTokens), - Interlocked.Read(ref _outputTokens), - Interlocked.Read(ref _reasoningTokens), - Interlocked.Read(ref _totalTokens)); + public TokenUsageSnapshot UsageSnapshot => _budget.UsageSnapshot; public override async Task GetResponseAsync( IEnumerable messages, @@ -68,29 +71,56 @@ public override async IAsyncEnumerable GetStreamingResponseA private void Track(UsageDetails? usage) { - if (usage is null) - { - return; - } + _budget.Track(usage); + } - Interlocked.Add(ref _inputTokens, usage.InputTokenCount ?? 0); - Interlocked.Add(ref _outputTokens, usage.OutputTokenCount ?? 0); - if (usage.AdditionalCounts is { } additionalCounts && - additionalCounts.TryGetValue(ReasoningTokenCountKey, out long reasoningTokens)) - { - Interlocked.Add(ref _reasoningTokens, reasoningTokens); - } + private sealed class TokenBudget + { + private readonly long _maxTotalTokens; + private long _totalTokens; + private long _inputTokens; + private long _outputTokens; + private long _reasoningTokens; - long used = usage.TotalTokenCount ?? 0; - if (used == 0) + public TokenBudget(long maxTotalTokens) { - return; + _maxTotalTokens = maxTotalTokens > 0 + ? maxTotalTokens + : throw new ArgumentOutOfRangeException(nameof(maxTotalTokens), maxTotalTokens, "Token cap must be a positive number."); } - long total = Interlocked.Add(ref _totalTokens, used); - if (total > _maxTotalTokens) + public TokenUsageSnapshot UsageSnapshot => new( + Interlocked.Read(ref _inputTokens), + Interlocked.Read(ref _outputTokens), + Interlocked.Read(ref _reasoningTokens), + Interlocked.Read(ref _totalTokens)); + + public void Track(UsageDetails? usage) { - throw new TokenCapExceededException(total, _maxTotalTokens); + if (usage is null) + { + return; + } + + Interlocked.Add(ref _inputTokens, usage.InputTokenCount ?? 0); + Interlocked.Add(ref _outputTokens, usage.OutputTokenCount ?? 0); + if (usage.AdditionalCounts is { } additionalCounts && + additionalCounts.TryGetValue(ReasoningTokenCountKey, out long reasoningTokens)) + { + Interlocked.Add(ref _reasoningTokens, reasoningTokens); + } + + long used = usage.TotalTokenCount ?? 0; + if (used == 0) + { + return; + } + + long total = Interlocked.Add(ref _totalTokens, used); + if (total > _maxTotalTokens) + { + throw new TokenCapExceededException(total, _maxTotalTokens); + } } } } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..bc33cfa --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,87 @@ +# Architecture + +BlogWriter is a Microsoft Agent Framework (MAF) application split into two parts: + +1. **Console app** (this repo's root) — orchestrates a bounded revision workflow and never talks to an LLM directly. +2. **Four Azure AI Foundry Hosted Agents** (`HostedAgents/Blogger`, `HostedAgents/Researcher`, `HostedAgents/Author`, `HostedAgents/Reviewer`) — independently deployed, each with its own managed compute and Microsoft Entra ID identity, exposing an OpenAI-compatible `/responses` endpoint. + +```mermaid +flowchart LR + subgraph Local["Console app (Program.cs / BlogWorkflow.cs)"] + Blogger[BloggerExecutor] + Researcher[ResearcherExecutor] + Author[AuthorExecutor] + Reviewer[ReviewerExecutor] + Blogger --> Researcher --> Author --> Reviewer + Reviewer -. "NeedsRevision == true" .-> Author + end + + subgraph Foundry["Azure AI Foundry (hosted agents, deployed independently via azd)"] + HBlogger["Blogger agent
(routing)"] + HResearcher["Researcher agent
(HostedWebSearchTool)"] + HAuthor["Author agent
(drafts/revises)"] + HReviewer["Reviewer agent
(approve/reject)"] + end + + Blogger -. "AsAIAgent().RunAsync()" .-> HBlogger + Researcher -. "AsAIAgent().RunAsync()" .-> HResearcher + Author -. "AsAIAgent().RunAsync()" .-> HAuthor + Reviewer -. "AsAIAgent().RunAsync()" .-> HReviewer +``` + +## Console app: orchestration only + +`Program.cs` never builds an agent in-process. For each of the four roles it calls +`AIProjectClient.AsAIAgent(agentEndpoint, ...)`, which returns a MAF `AIAgent` that +talks to the already-deployed hosted agent's `/responses` endpoint. `BlogWorkflow.cs` +wires those four `AIAgent`s into a MAF `WorkflowBuilder` graph +(`Workflows/BlogExecutors.cs` holds the `[MessageHandler]` executors): + +``` +Blogger → Researcher → Author → Reviewer + ↑ | + └── (if state.NeedsRevision) ──┘ +``` + +The revision loop is bounded by `ResearchState.MaxRevisions` — the workflow always +terminates, either on reviewer approval or on hitting the revision cap. + +**All agent connectivity goes through MAF (`AsAIAgent` / `AIAgent.RunAsync`) — the +console app never issues a raw HTTP call to an agent endpoint.** This is a hard +constraint of this codebase (see `AGENTS.md`), not just a convention. + +## Authentication + +Every hop — console app → hosted agent, and hosted agent → Foundry model/tools — uses +Microsoft Entra ID exclusively (`AzureCliCredential` locally, `DefaultAzureCredential` in +the hosted agents). There are no API keys anywhere in this architecture. + +## Shared token budget + +`TokenCapChatClient.CreateSharedFactory(maxTotalTokens)` produces one `IChatClient` +middleware factory that is passed to every `AsAIAgent(..., clientFactory: ...)` call in +`Program.cs`. All four agents share a single cumulative token counter for the lifetime of +one console-app run (default cap: 40,000 tokens, `MAX_TOTAL_TOKENS`). Exceeding it throws +`TokenCapExceededException`, which unwinds the workflow and exits the app gracefully +instead of continuing to spend tokens. + +## Hosted web search (Researcher) + +The Researcher hosted agent owns a Foundry-native `HostedWebSearchTool()` — see +`HostedAgents/Researcher/Program.cs`. Web search executes **inside the hosted agent +process**, not in the console app; the console app only sees the final findings text. + +## Deployment model + +Each `HostedAgents/` project is deployed independently via `azd` and is +**pre-provisioned** — the console app only references already-deployed hosted agents by +name (`BLOGGER_AGENT_NAME`, `RESEARCHER_AGENT_NAME`, `AUTHOR_AGENT_NAME`, +`REVIEWER_AGENT_NAME`), it never creates or updates them at runtime. See +[deployment.md](deployment.md) for the full `azd` flow and +[configuration.md](configuration.md) for every environment variable involved. + +## Known limitation + +We're currently seeing more LLM calls than expected during a single workflow run. +It's not yet confirmed whether this is a real extra-call issue or a telemetry +over-count — flagging it here as an open item rather than a resolved one. diff --git a/docs/changelog-v1-to-v2.md b/docs/changelog-v1-to-v2.md new file mode 100644 index 0000000..627804a --- /dev/null +++ b/docs/changelog-v1-to-v2.md @@ -0,0 +1,53 @@ +# Changelog: v1 → current (hosted agents) + +This summarizes what changed in the `hostedAgents` branch versus the original +in-process version of BlogWriter, for anyone picking the project back up. + +## 1. Agents moved from in-process to independently deployed Foundry Hosted Agents + +**Before:** Blogger/Researcher/Author/Reviewer were built and run in-process inside the +console app. + +**Now:** each agent is its own project under `HostedAgents//`, deployed +independently via `azd` to Azure AI Foundry Agent Service, with its own compute, +Entra ID identity, and OpenAI-compatible `/responses` endpoint. The console app only +references them by name (`AsAIAgent(agentEndpoint, ...)`) — it never builds, provisions, +or updates them at runtime. See [architecture.md](architecture.md). + +## 2. Researcher's web search moved from a custom Tavily tool to Foundry-native `HostedWebSearchTool` + +**Before:** the Researcher agent called a custom Tavily HTTP function/tool with a +hand-written function schema. This schema was incompatible with the hosted Responses +endpoint and caused HTTP 400 failures during the migration to hosted agents. + +**Now:** the Researcher hosted agent uses Foundry's built-in `HostedWebSearchTool()` +(see `HostedAgents/Researcher/Program.cs`). Search executes server-side inside the +hosted agent process; there's no Tavily API key or custom tool schema to maintain. + +## 3. Per-call token limit replaced with a shared, cumulative token budget + +**Before:** a per-call `MAX_OUTPUT_TOKENS` request option was sent on every model call. +This option isn't supported by the Foundry Hosted Agent Responses endpoints and was +dead weight even before that. + +**Now:** `TokenCapChatClient.CreateSharedFactory(maxTotalTokens)` wraps all four agents +via MAF's `clientFactory` hook, tracking one cumulative token count across the whole +workflow run (`MAX_TOTAL_TOKENS`, default 40,000). Exceeding it throws +`TokenCapExceededException` and the app shuts down gracefully instead of continuing to +spend tokens. + +## 4. All agent connectivity now goes through Microsoft Agent Framework — never raw HTTP + +**Before/during migration:** there was a temptation (and at one point, an attempt) to +call agent endpoints directly over HTTP. + +**Now:** every agent call is `AIProjectClient.AsAIAgent(...)` + `AIAgent.RunAsync(...)`. +This is enforced as a hard constraint in `AGENTS.md`/MAF Doctor guidance, not just a +style preference — direct HTTP calls to agent endpoints should be treated as a bug if +seen again. + +## Known limitation carried forward + +The original README's "Known Issues" note — more LLM calls than expected per run, +possibly a telemetry over-count rather than a real issue — is still open. See +[architecture.md](architecture.md#known-limitation). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..24b8750 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,43 @@ +# Configuration reference + +All values below are read from environment variables, with `dotnet user-secrets` +recommended for local development of the console app (secrets win over environment +variables on key collisions). None of the four hosted agents or the console app use API +keys — every credential is Microsoft Entra ID (`AzureCliCredential` locally, +`DefaultAzureCredential` in hosted agents). + +## Console app (`BlogWriter.csproj`, root `Program.cs`) + +| Key | Required | Default | Notes | +| --- | --- | --- | --- | +| `FOUNDRY_PROJECT_ENDPOINT` | yes | — | e.g. `https://.services.ai.azure.com/api/projects/` | +| `AZURE_TENANT_ID` | yes | — | Microsoft Entra tenant hosting the Foundry project | +| `BLOGGER_AGENT_NAME` | no | `Blogger` | Name of the deployed hosted agent to call | +| `RESEARCHER_AGENT_NAME` | no | `Researcher` | | +| `AUTHOR_AGENT_NAME` | no | `Author` | | +| `REVIEWER_AGENT_NAME` | no | `Reviewer` | | +| `MAX_TOTAL_TOKENS` | no | `40000` | Cumulative cross-agent token cap (`TokenCapChatClient`); parse failures fall back to the default | + +Set with, e.g.: + +```powershell +dotnet user-secrets set "FOUNDRY_PROJECT_ENDPOINT" "https://.services.ai.azure.com/api/projects/" +dotnet user-secrets set "AZURE_TENANT_ID" "" +``` + +## Each hosted agent (`HostedAgents/Blogger`, `Researcher`, `Author`, `Reviewer`) + +| Key | Required | Default | Notes | +| --- | --- | --- | --- | +| `FOUNDRY_PROJECT_ENDPOINT` | yes | — | Same Foundry project the console app points at | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` | Model deployment used by that specific agent; set per-project in its own `azure.yaml` | + +These are set as `environmentVariables` in each project's `azure.yaml` and provisioned by +`azd` — see [deployment.md](deployment.md). They're not read from `dotnet user-secrets` +since hosted agents run in Azure, not locally, once deployed. + +## Keeping prompts in sync + +Each hosted agent's `AgentPrompt.cs` must be kept in sync with the corresponding section +of the console app's `Prompts.cs`. There's no automated check for this today — when +changing one, update the other. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..ec1dde0 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,82 @@ +# Deployment guide + +BlogWriter has two independently deployable parts: the four Azure AI Foundry Hosted +Agents, and the console app that orchestrates them. See +[architecture.md](architecture.md) for how they fit together and +[configuration.md](configuration.md) for the full environment variable reference. + +## 1. One-time setup + +```powershell +azd ext install microsoft.foundry +azd auth login +az login +``` + +## 2. Deploy (or redeploy) a hosted agent + +From inside each `HostedAgents/` folder (`Blogger`, `Researcher`, `Author`, +`Reviewer`): + +```powershell +# First time only per project — scaffold/replace azure.yaml against your real Foundry project +azd ai agent init --deploy-mode code + +# Provision Foundry project/model/ACR resources (skip if reusing an existing project) +azd provision + +# Test locally before shipping +azd ai agent run +azd ai agent invoke "Hello!" + +# Deploy the source to Foundry Agent Service (direct code deploy, no container build) +azd deploy + +# Test the deployed version and stream its logs +azd ai agent invoke --new-session "Hello!" +azd ai agent monitor --tail 100 +azd ai agent monitor --tail 100 --type system +``` + +Repeat `azd deploy` for each of the four agents whenever their code changes — they +deploy independently of each other and of the console app. + +> `azure.yaml` in each folder is a starting-point manifest, not a generated one — +> regenerate it via `azd ai agent init` against your real Foundry project before +> deploying for real. `Microsoft.Agents.AI.Foundry.Hosting` is still a **prerelease** +> package; re-validate versions before production use. + +## 3. Run the console app locally + +The console app is never deployed to Azure — it runs locally and calls the four +already-deployed hosted agents by name over the network. + +```powershell +dotnet user-secrets set "FOUNDRY_PROJECT_ENDPOINT" "https://.services.ai.azure.com/api/projects/" +dotnet user-secrets set "AZURE_TENANT_ID" "" +dotnet run --project . +``` + +It will prompt for a topic and a min/max word count, then stream workflow progress +(`[trace] → ...` / `[trace] ← ...` lines) before printing the final approved draft. + +## 4. Verifying a deployment + +After `azd deploy` for a given agent, confirm it's healthy before wiring the console app +to it: + +1. `azd ai agent invoke --new-session ""` — should return real assistant + text, not an error. +2. `azd ai agent monitor --tail 100` — look for `HTTP 200` on the `/responses` request + and no unhandled exceptions. Startup warnings about Kestrel address binding or a 404 + on the very first task-storage lookup (before the task exists) are expected noise, not + failures. +3. Run the console app end-to-end once against the redeployed agent and confirm the + reviewer reaches `APPROVED` (or a clear revision-cap message) with no exceptions. + +## Never do this + +Do not call an agent's `/responses` endpoint directly with `HttpClient` or similar, in +either the console app or a hosted agent. All agent-to-agent and app-to-agent +communication must go through Microsoft Agent Framework (`AsAIAgent` / +`AIAgent.RunAsync`) — see [architecture.md](architecture.md).