From 1f6f42e31e9355b6bf1fa9f26027da548662d84a Mon Sep 17 00:00:00 2001 From: Bruno Capuano Date: Thu, 3 Sep 2026 16:11:32 -0300 Subject: [PATCH 1/5] Add hosted agent implementations --- BlogWriter.csproj | 12 +- HostedAgents/Author/.agentignore | 43 +++++ HostedAgents/Author/Author.HostedAgent.csproj | 25 +++ HostedAgents/Author/Program.cs | 28 ++++ HostedAgents/Author/README.md | 17 ++ HostedAgents/Author/azure.yaml | 31 ++++ HostedAgents/Blogger/.agentignore | 43 +++++ HostedAgents/Blogger/.gitignore | 1 + .../Blogger/Blogger.HostedAgent.csproj | 25 +++ HostedAgents/Blogger/Program.cs | 29 ++++ HostedAgents/Blogger/README.md | 17 ++ HostedAgents/Blogger/azure.yaml | 31 ++++ HostedAgents/README.md | 55 ++++++ HostedAgents/Researcher/.agentignore | 43 +++++ HostedAgents/Researcher/Program.cs | 93 +++++++++++ HostedAgents/Researcher/README.md | 22 +++ .../Researcher/Researcher.HostedAgent.csproj | 25 +++ HostedAgents/Researcher/azure.yaml | 31 ++++ HostedAgents/Reviewer/.agentignore | 43 +++++ HostedAgents/Reviewer/Program.cs | 28 ++++ HostedAgents/Reviewer/README.md | 17 ++ .../Reviewer/Reviewer.HostedAgent.csproj | 25 +++ HostedAgents/Reviewer/azure.yaml | 31 ++++ Program.cs | 156 +++++++----------- README.md | 54 +++++- RemoteHostedAgentChatClient.cs | 138 ++++++++++++++++ ResearcherAgent.cs | 28 +--- 27 files changed, 967 insertions(+), 124 deletions(-) create mode 100644 HostedAgents/Author/.agentignore create mode 100644 HostedAgents/Author/Author.HostedAgent.csproj create mode 100644 HostedAgents/Author/Program.cs create mode 100644 HostedAgents/Author/README.md create mode 100644 HostedAgents/Author/azure.yaml create mode 100644 HostedAgents/Blogger/.agentignore create mode 100644 HostedAgents/Blogger/.gitignore create mode 100644 HostedAgents/Blogger/Blogger.HostedAgent.csproj create mode 100644 HostedAgents/Blogger/Program.cs create mode 100644 HostedAgents/Blogger/README.md create mode 100644 HostedAgents/Blogger/azure.yaml create mode 100644 HostedAgents/README.md create mode 100644 HostedAgents/Researcher/.agentignore create mode 100644 HostedAgents/Researcher/Program.cs create mode 100644 HostedAgents/Researcher/README.md create mode 100644 HostedAgents/Researcher/Researcher.HostedAgent.csproj create mode 100644 HostedAgents/Researcher/azure.yaml create mode 100644 HostedAgents/Reviewer/.agentignore create mode 100644 HostedAgents/Reviewer/Program.cs create mode 100644 HostedAgents/Reviewer/README.md create mode 100644 HostedAgents/Reviewer/Reviewer.HostedAgent.csproj create mode 100644 HostedAgents/Reviewer/azure.yaml create mode 100644 RemoteHostedAgentChatClient.cs diff --git a/BlogWriter.csproj b/BlogWriter.csproj index c1243c7..a2f4a53 100644 --- a/BlogWriter.csproj +++ b/BlogWriter.csproj @@ -11,8 +11,15 @@ - + + + + + + + @@ -22,14 +29,11 @@ all - - - 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/Author.HostedAgent.csproj b/HostedAgents/Author/Author.HostedAgent.csproj new file mode 100644 index 0000000..db5588c --- /dev/null +++ b/HostedAgents/Author/Author.HostedAgent.csproj @@ -0,0 +1,25 @@ + + + + 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..0c73ea2 --- /dev/null +++ b/HostedAgents/Author/README.md @@ -0,0 +1,17 @@ +# 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 sourced from `BlogWriter.Prompts.AuthorInstructions` (shared +with the console app via a `ProjectReference` to `../../BlogWriter.csproj`) — +no local prompt duplication. diff --git a/HostedAgents/Author/azure.yaml b/HostedAgents/Author/azure.yaml new file mode 100644 index 0000000..094d3cb --- /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: bin/Debug/net10.0/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/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/Blogger.HostedAgent.csproj b/HostedAgents/Blogger/Blogger.HostedAgent.csproj new file mode 100644 index 0000000..ad1d44f --- /dev/null +++ b/HostedAgents/Blogger/Blogger.HostedAgent.csproj @@ -0,0 +1,25 @@ + + + + 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..1a8aa16 --- /dev/null +++ b/HostedAgents/Blogger/README.md @@ -0,0 +1,17 @@ +# 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 sourced from `BlogWriter.Prompts.BloggerInstructions` (shared +with the console app via a `ProjectReference` to `../../BlogWriter.csproj`) — +no local prompt duplication. diff --git a/HostedAgents/Blogger/azure.yaml b/HostedAgents/Blogger/azure.yaml new file mode 100644 index 0000000..5e2ac0e --- /dev/null +++ b/HostedAgents/Blogger/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: 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: bin/Debug/net10.0/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 +infra: + provider: microsoft.foundry diff --git a/HostedAgents/README.md b/HostedAgents/README.md new file mode 100644 index 0000000..e8f58ab --- /dev/null +++ b/HostedAgents/README.md @@ -0,0 +1,55 @@ +# 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 the Tavily search tool + its API key | +| `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` (and, for +Researcher, the `TAVILY_API_KEY` user-secret) 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/.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/Program.cs b/HostedAgents/Researcher/Program.cs new file mode 100644 index 0000000..2545e73 --- /dev/null +++ b/HostedAgents/Researcher/Program.cs @@ -0,0 +1,93 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +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; +using Microsoft.Extensions.Configuration; + +// 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 the Tavily web-search +// tool: the tool call happens inside this hosted process, not in the main +// console app, so its HTTP client, retry logic, and TAVILY_API_KEY secret all +// live here. + +IConfiguration config = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddUserSecrets(typeof(Program).Assembly) + .Build(); + +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"; +string tavilyApiKey = config["TAVILY_API_KEY"] + ?? throw new InvalidOperationException( + "Missing configuration value 'TAVILY_API_KEY'. Set it with: dotnet user-secrets set \"TAVILY_API_KEY\" \"\""); + +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. +// (Ported unchanged from the console app's pre-migration Program.cs.) +async Task PostWithRetryAsync(string requestUri, object body, CancellationToken cancellationToken) +{ + 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); + } + } +} + +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."); + +// Entra ID only — no API keys, per repository constraint (this applies to the +// Foundry/model auth; the Tavily key above is a third-party API key, not Foundry auth). +AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) + .AsAIAgent( + model: modelDeployment, + instructions: BlogWriter.Prompts.ResearcherInstructions, + name: "Researcher", + tools: [tavilyTool]); + +var builder = AgentHost.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses()); + +var app = builder.Build(); +app.Run(); + +// Needed for AddUserSecrets(typeof(Program).Assembly) with top-level statements. +internal partial class Program; diff --git a/HostedAgents/Researcher/README.md b/HostedAgents/Researcher/README.md new file mode 100644 index 0000000..fa8bf6b --- /dev/null +++ b/HostedAgents/Researcher/README.md @@ -0,0 +1,22 @@ +# 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 the **Tavily web-search +tool**: the tool call, its `HttpClient`, and retry logic run inside this +hosted process (see `Program.cs`), 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` | | +| `TAVILY_API_KEY` | yes | — | Set via `dotnet user-secrets set "TAVILY_API_KEY" ""` for local runs; use the hosted environment's secret store for deployed runs | + +Instructions are sourced from `BlogWriter.Prompts.ResearcherInstructions` +(shared with the console app via a `ProjectReference` to +`../../BlogWriter.csproj`) — no local prompt duplication. diff --git a/HostedAgents/Researcher/Researcher.HostedAgent.csproj b/HostedAgents/Researcher/Researcher.HostedAgent.csproj new file mode 100644 index 0000000..94503d6 --- /dev/null +++ b/HostedAgents/Researcher/Researcher.HostedAgent.csproj @@ -0,0 +1,25 @@ + + + + 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..57f8c50 --- /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: bin/Debug/net10.0/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/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/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..979ff35 --- /dev/null +++ b/HostedAgents/Reviewer/README.md @@ -0,0 +1,17 @@ +# 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 sourced from `BlogWriter.Prompts.ReviewerInstructions` +(shared with the console app via a `ProjectReference` to +`../../BlogWriter.csproj`) — no local prompt duplication. diff --git a/HostedAgents/Reviewer/Reviewer.HostedAgent.csproj b/HostedAgents/Reviewer/Reviewer.HostedAgent.csproj new file mode 100644 index 0000000..f93f420 --- /dev/null +++ b/HostedAgents/Reviewer/Reviewer.HostedAgent.csproj @@ -0,0 +1,25 @@ + + + + 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..4fc2a96 --- /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: bin/Debug/net10.0/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/Program.cs b/Program.cs index 135193c..8bf9110 100644 --- a/Program.cs +++ b/Program.cs @@ -1,13 +1,14 @@ -using System.ClientModel; -using System.Diagnostics; -using System.Net.Http.Headers; -using System.Net.Http.Json; +using System.Diagnostics; +using Azure.Identity; using BlogWriter; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +<<<<<<< HEAD using ModelContextProtocol.Client; using OpenAI; +======= +>>>>>>> c05a6c3 (Add hosted agent implementations) // Secrets come from the .NET user-secrets store and from // environment variables (secrets win on key collisions). @@ -20,52 +21,46 @@ 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"); +// 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 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"; // 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; long maxTotalTokens = long.TryParse(config["MAX_TOTAL_TOKENS"], out long configuredMaxTotalTokens) ? configuredMaxTotalTokens : 40000; -if (!Uri.TryCreate(openAiApiBase, UriKind.Absolute, out var uri)) + +// Entra ID only — no API keys, per repository constraint. One credential and +// HttpClient are shared across all 4 remote hosted-agent chat clients. +var azureCredential = new DefaultAzureCredential(); +using var hostedAgentHttpClient = new HttpClient(); + +// Builds one IChatClient per hosted agent, each still wrapped with function +// invocation, OpenTelemetry, and a shared TokenCapChatClient — identical +// middleware pipeline to the pre-migration single shared client, just fanned +// out to 4 remote transports instead of 1. +List tokenCapChatClients = []; +IChatClient BuildAgentChatClient(string hostedAgentName) { - throw new InvalidOperationException($"Invalid URI: '{openAiApiBase}'"); + TokenCapChatClient? tokenCap = null; + IChatClient client = new RemoteHostedAgentChatClient(hostedAgentHttpClient, azureCredential, foundryProjectEndpoint, hostedAgentName) + .AsBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry(sourceName: "BlogWriter.ChatClient") + .Use(inner => tokenCap = new TokenCapChatClient(inner, maxTotalTokens)) + .Build(); + tokenCapChatClients.Add(tokenCap!); + return client; } -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(); +IChatClient bloggerLlm = BuildAgentChatClient(bloggerAgentName); +IChatClient researcherLlm = BuildAgentChatClient(researcherAgentName); +IChatClient authorLlm = BuildAgentChatClient(authorAgentName); +IChatClient reviewerLlm = BuildAgentChatClient(reviewerAgentName); var chatOptions = new ChatOptions { @@ -73,49 +68,6 @@ string GetRequired(string key) => 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) -{ - 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); - } - } -} - -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."); - // Creating a callable object using ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); ILogger startupLogger = loggerFactory.CreateLogger("BlogWriter.Startup"); @@ -141,10 +93,17 @@ async Task PostWithRetryAsync(string requestUri, object bod startupLogger.LogWarning(ex, "Microsoft Learn MCP server unavailable; continuing with Tavily-only research tools."); } +<<<<<<< HEAD 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, chatOptions, loggerFactory.CreateLogger()); +var researcherAgent = new ResearcherAgent(researcherLlm, chatOptions, loggerFactory.CreateLogger()); +var authorAgent = new AuthorAgent(authorLlm, chatOptions, loggerFactory.CreateLogger()); +var reviewerAgent = new ReviewerAgent(reviewerLlm, chatOptions, loggerFactory.CreateLogger()); +>>>>>>> c05a6c3 (Add hosted agent implementations) var app = new BlogWorkflow(bloggerAgent, researcherAgent, authorAgent, reviewerAgent, loggerFactory.CreateLogger()); // Distributed tracing: an ActivityListener activates every "BlogWriter.*" @@ -257,14 +216,23 @@ int ReadWordCount(string prompt, int defaultValue, int? minimum = null) } Console.WriteLine("\n=============================\n"); -if (tokenCapChatClient is not null) +// Aggregate usage across all 4 remote hosted-agent chat clients (one +// TokenCapChatClient per agent, replacing the single shared instance from +// before the migration). +long totalInput = 0, totalOutput = 0, totalReasoning = 0, totalTokens = 0; +foreach (TokenCapChatClient tokenCap in tokenCapChatClients) { - 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("=================================="); + TokenUsageSnapshot usage = tokenCap.UsageSnapshot; + totalInput += usage.InputTokens; + totalOutput += usage.OutputTokens; + totalReasoning += usage.ReasoningTokens; + totalTokens += usage.TotalTokens; } +Console.WriteLine("\n========== TOKEN USAGE =========="); +Console.WriteLine($"Input tokens: {totalInput}"); +Console.WriteLine($"Output tokens: {totalOutput}"); +Console.WriteLine($"Reasoning tokens: {totalReasoning}"); +Console.WriteLine($"Total tokens: {totalTokens}"); +Console.WriteLine("=================================="); + diff --git a/README.md b/README.md index baf8e5a..6eb3ff9 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,58 @@ # 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` +(`RemoteHostedAgentChatClient.cs`). + +``` +BlogWriter/ (console app — orchestration only, calls hosted agents remotely) +HostedAgents/ + Blogger/ (Foundry Hosted Agent — orchestration decisions) + Researcher/ (Foundry Hosted Agent — owns the Tavily web-search tool) + 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/` | +| `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_OUTPUT_TOKENS` | no | `4096` | Per-call cap | +| `MAX_TOTAL_TOKENS` | no | `40000` | Cumulative process-wide cap (`TokenCapChatClient`) | + +`TAVILY_API_KEY` is no longer configured here — it now lives in the +Researcher hosted agent's own configuration (see `HostedAgents/Researcher/README.md`). + ## 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. +* Tavily web search now runs **inside the hosted Researcher agent** — the tool call, HTTP client, and retry logic live in `HostedAgents/Researcher/Program.cs`. +* 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 +63,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/RemoteHostedAgentChatClient.cs b/RemoteHostedAgentChatClient.cs new file mode 100644 index 0000000..3dd3a6e --- /dev/null +++ b/RemoteHostedAgentChatClient.cs @@ -0,0 +1,138 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Core; +using Microsoft.Extensions.AI; + +namespace BlogWriter; + +/// +/// that talks to a single Azure AI Foundry Hosted +/// Agent over its OpenAI-compatible Responses protocol endpoint +/// ({project_endpoint}/agents/{name}/endpoint/protocols/openai/responses), +/// authenticated with a Microsoft Entra ID bearer token (no API keys — per the +/// project's hard constraint on Entra-only auth). +/// +/// This is a minimal, self-contained client: it posts the conversation as a +/// single input string (system/user turns concatenated) and reads back +/// output_text. It composes normally with the rest of the +/// pipeline (function invocation, OpenTelemetry, +/// ) since those only depend on the +/// abstraction, not the transport. +/// +/// Streaming is not implemented against the hosted protocol yet; it falls back +/// to a single update built from the non-streaming response. +/// +public sealed class RemoteHostedAgentChatClient : IChatClient +{ + // Default Entra ID scope for Foundry Agent Service data-plane calls. + // Confirm this against the target Foundry resource before production use — + // some deployments may require "https://cognitiveservices.azure.com/.default". + public const string DefaultScope = "https://ai.azure.com/.default"; + + private readonly HttpClient _httpClient; + private readonly TokenCredential _credential; + private readonly string _scope; + private readonly Uri _responsesEndpoint; + + /// Shared HttpClient; caller owns disposal. + /// Entra ID credential, e.g. DefaultAzureCredential. + /// Foundry project endpoint, e.g. https://<account>.services.ai.azure.com/api/projects/<project>. + /// Name of the deployed hosted agent (pre-provisioned via azd; not created at runtime). + /// Entra ID token scope; defaults to . + public RemoteHostedAgentChatClient( + HttpClient httpClient, + TokenCredential credential, + Uri projectEndpoint, + string hostedAgentName, + string scope = DefaultScope) + { + _httpClient = httpClient; + _credential = credential; + _scope = scope; + _responsesEndpoint = new Uri( + projectEndpoint, + $"agents/{hostedAgentName}/endpoint/protocols/openai/responses"); + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + string input = BuildInput(messages); + + using HttpRequestMessage request = new(HttpMethod.Post, _responsesEndpoint) + { + Content = JsonContent.Create(new HostedAgentRequest(input, options?.MaxOutputTokens, options?.Temperature)), + }; + + AccessToken token = await _credential.GetTokenAsync(new TokenRequestContext([_scope]), cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + HostedAgentResponse? payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + string text = payload?.OutputText ?? string.Empty; + + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, text)); + if (payload?.Usage is { } usage) + { + chatResponse.Usage = new UsageDetails + { + InputTokenCount = usage.InputTokens, + OutputTokenCount = usage.OutputTokens, + TotalTokenCount = usage.TotalTokens, + }; + } + + return chatResponse; + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // No native streaming support against the hosted Responses endpoint yet; + // surface the full response as a single update. + ChatResponse response = await GetResponseAsync(messages, options, cancellationToken); + foreach (ChatMessage message in response.Messages) + { + yield return new ChatResponseUpdate(message.Role, message.Contents); + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + // _httpClient is owned by the caller (shared across agents); nothing to dispose here. + } + + private static string BuildInput(IEnumerable messages) + { + // Concatenate system/user turns into the single "input" string the + // hosted Responses endpoint accepts (see the /responses curl sample in + // the Foundry Hosted Agents docs). The hosted agent's own baked-in + // instructions still apply server-side; this preserves the same + // client-supplied Instructions/user-message behaviour the app relied on + // pre-migration. + return string.Join("\n\n", messages.Select(m => $"[{m.Role}] {m.Text}")); + } + + private sealed record HostedAgentRequest( + [property: JsonPropertyName("input")] string Input, + [property: JsonPropertyName("max_output_tokens")] int? MaxOutputTokens, + [property: JsonPropertyName("temperature")] float? Temperature); + + private sealed record HostedAgentResponse( + [property: JsonPropertyName("output_text")] string? OutputText, + [property: JsonPropertyName("usage")] HostedAgentUsage? Usage); + + private sealed record HostedAgentUsage( + [property: JsonPropertyName("input_tokens")] int? InputTokens, + [property: JsonPropertyName("output_tokens")] int? OutputTokens, + [property: JsonPropertyName("total_tokens")] int? TotalTokens); +} diff --git a/ResearcherAgent.cs b/ResearcherAgent.cs index 0d809af..fa4f769 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 Tavily web-search tool now 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 { @@ -29,13 +31,11 @@ public class ResearcherAgent : IResearcherAgent // 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(IChatClient llm, ChatOptions chatOptions, ILogger logger) { _logger = logger; _maxOutputTokens = chatOptions.MaxOutputTokens; - List toolList = [.. tools]; - _agent = new ChatClientAgent(llm, new ChatClientAgentOptions { // Name surfaces in OpenTelemetry traces and agent logs. @@ -48,27 +48,13 @@ public ResearcherAgent(IChatClient llm, ChatOptions chatOptions, IEnumerable - { - _logger.LogInformation( - "Researcher invoking tool '{Tool}'...", - context.Function.Name); - - return await next(context, cancellationToken); - }) .UseOpenTelemetry(sourceName: "BlogWriter.Agents") .Build(); - _logger.LogInformation( - "ResearcherAgent initialized with tools: {ToolNames}", - string.Join(", ", toolList.Select(t => t.Name))); + _logger.LogInformation("ResearcherAgent initialized (Tavily tool runs inside the hosted Researcher agent)."); } /// Execute research by letting the agent search and summarise. From 45fb0f652318fd9b383081e9e28cac21fd2703ab Mon Sep 17 00:00:00 2001 From: Bruno Capuano Date: Mon, 7 Sep 2026 09:28:13 -0300 Subject: [PATCH 2/5] feat: Update hosted agents to use Microsoft Agent Framework and improve configuration - Added support for Microsoft Foundry hosted agents in AGENTS.md. - Refactored AuthorAgent and BloggerAgent to accept AIAgent instead of IChatClient. - Updated BlogWriter.csproj to include Microsoft.Agents.AI.Foundry package. - Removed ProjectReferences for shared prompts in hosted agent projects. - Introduced AgentPrompt.cs files for each hosted agent with specific instructions. - Updated eval.yaml files for hosted agents to include new configurations. - Enhanced README.md with new environment variable requirements. - Removed RemoteHostedAgentChatClient as it is no longer needed. - Added detailed instructions for each agent in their respective .agent_configs. --- .gitignore | 3 + AGENTS.md | 4 + AuthorAgent.cs | 16 +- BlogWriter.csproj | 3 +- BloggerAgent.cs | 24 ++- .../.agent_configs/baseline/instructions.md | 1 + .../.agent_configs/baseline/metadata.yaml | 1 + HostedAgents/Author/AgentPrompt.cs | 19 +++ HostedAgents/Author/Author.HostedAgent.csproj | 6 - HostedAgents/Author/README.md | 5 +- HostedAgents/Author/azure.yaml | 52 +++---- HostedAgents/Author/eval.yaml | 8 + .../.agent_configs/baseline/instructions.md | 1 + .../.agent_configs/baseline/metadata.yaml | 1 + HostedAgents/Blogger/AgentPrompt.cs | 21 +++ .../Blogger/Blogger.HostedAgent.csproj | 6 - HostedAgents/Blogger/README.md | 5 +- HostedAgents/Blogger/azure.yaml | 52 +++---- HostedAgents/Blogger/eval.yaml | 8 + .../.agent_configs/baseline/instructions.md | 1 + .../.agent_configs/baseline/metadata.yaml | 1 + HostedAgents/Researcher/AgentPrompt.cs | 19 +++ HostedAgents/Researcher/README.md | 5 +- .../Researcher/Researcher.HostedAgent.csproj | 6 - HostedAgents/Researcher/azure.yaml | 54 +++---- HostedAgents/Researcher/eval.yaml | 8 + .../.agent_configs/baseline/instructions.md | 1 + .../.agent_configs/baseline/metadata.yaml | 1 + HostedAgents/Reviewer/AgentPrompt.cs | 24 +++ HostedAgents/Reviewer/README.md | 5 +- .../Reviewer/Reviewer.HostedAgent.csproj | 6 - HostedAgents/Reviewer/azure.yaml | 52 +++---- HostedAgents/Reviewer/eval.yaml | 8 + Program.cs | 60 +++----- README.md | 3 +- RemoteHostedAgentChatClient.cs | 138 ------------------ ResearcherAgent.cs | 20 +-- ReviewerAgent.cs | 16 +- 38 files changed, 282 insertions(+), 382 deletions(-) create mode 100644 HostedAgents/Author/.agent_configs/baseline/instructions.md create mode 100644 HostedAgents/Author/.agent_configs/baseline/metadata.yaml create mode 100644 HostedAgents/Author/AgentPrompt.cs create mode 100644 HostedAgents/Author/eval.yaml create mode 100644 HostedAgents/Blogger/.agent_configs/baseline/instructions.md create mode 100644 HostedAgents/Blogger/.agent_configs/baseline/metadata.yaml create mode 100644 HostedAgents/Blogger/AgentPrompt.cs create mode 100644 HostedAgents/Blogger/eval.yaml create mode 100644 HostedAgents/Researcher/.agent_configs/baseline/instructions.md create mode 100644 HostedAgents/Researcher/.agent_configs/baseline/metadata.yaml create mode 100644 HostedAgents/Researcher/AgentPrompt.cs create mode 100644 HostedAgents/Researcher/eval.yaml create mode 100644 HostedAgents/Reviewer/.agent_configs/baseline/instructions.md create mode 100644 HostedAgents/Reviewer/.agent_configs/baseline/metadata.yaml create mode 100644 HostedAgents/Reviewer/AgentPrompt.cs create mode 100644 HostedAgents/Reviewer/eval.yaml delete mode 100644 RemoteHostedAgentChatClient.cs 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..e10f24d 100644 --- a/AuthorAgent.cs +++ b/AuthorAgent.cs @@ -24,24 +24,12 @@ public class AuthorAgent : IAuthorAgent // 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, ChatOptions chatOptions, 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."); } diff --git a/BlogWriter.csproj b/BlogWriter.csproj index a2f4a53..6e38fd8 100644 --- a/BlogWriter.csproj +++ b/BlogWriter.csproj @@ -20,6 +20,7 @@ + @@ -28,7 +29,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/BloggerAgent.cs b/BloggerAgent.cs index 1d362b5..3c4d030 100644 --- a/BloggerAgent.cs +++ b/BloggerAgent.cs @@ -33,25 +33,23 @@ public class BloggerAgent : IBloggerAgent // 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, ChatOptions chatOptions, 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, + }), chatOptions, logger) + { } public async Task InvokeAsync(ResearchState state, CancellationToken cancellationToken = default) 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/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 index db5588c..8166305 100644 --- a/HostedAgents/Author/Author.HostedAgent.csproj +++ b/HostedAgents/Author/Author.HostedAgent.csproj @@ -10,12 +10,6 @@ 1288dcff-4dfa-44b7-a6d3-30ff6c988520 - - - - - diff --git a/HostedAgents/Author/README.md b/HostedAgents/Author/README.md index 0c73ea2..48acd33 100644 --- a/HostedAgents/Author/README.md +++ b/HostedAgents/Author/README.md @@ -12,6 +12,5 @@ Set before `azd ai agent run` / `azd deploy` (env vars, or via `azd env set`): | `FOUNDRY_PROJECT_ENDPOINT` | yes | — | | `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` | -Instructions are sourced from `BlogWriter.Prompts.AuthorInstructions` (shared -with the console app via a `ProjectReference` to `../../BlogWriter.csproj`) — -no local prompt duplication. +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 index 094d3cb..9eb2ef6 100644 --- a/HostedAgents/Author/azure.yaml +++ b/HostedAgents/Author/azure.yaml @@ -2,30 +2,30 @@ name: blogwriter-hosted-agent-author metadata: - template: blogwriter-hosted-agent + 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: bin/Debug/net10.0/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 + 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/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 index ad1d44f..e7c828a 100644 --- a/HostedAgents/Blogger/Blogger.HostedAgent.csproj +++ b/HostedAgents/Blogger/Blogger.HostedAgent.csproj @@ -10,12 +10,6 @@ c8b24fdb-2d17-44b8-827b-da514a8a9316 - - - - - diff --git a/HostedAgents/Blogger/README.md b/HostedAgents/Blogger/README.md index 1a8aa16..b4a15f5 100644 --- a/HostedAgents/Blogger/README.md +++ b/HostedAgents/Blogger/README.md @@ -12,6 +12,5 @@ Set before `azd ai agent run` / `azd deploy` (env vars, or via `azd env set`): | `FOUNDRY_PROJECT_ENDPOINT` | yes | — | | `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` | -Instructions are sourced from `BlogWriter.Prompts.BloggerInstructions` (shared -with the console app via a `ProjectReference` to `../../BlogWriter.csproj`) — -no local prompt duplication. +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 index 5e2ac0e..37ea89a 100644 --- a/HostedAgents/Blogger/azure.yaml +++ b/HostedAgents/Blogger/azure.yaml @@ -2,30 +2,30 @@ 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: bin/Debug/net10.0/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 + 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 infra: - provider: microsoft.foundry + provider: microsoft.foundry 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/Researcher/.agent_configs/baseline/instructions.md b/HostedAgents/Researcher/.agent_configs/baseline/instructions.md new file mode 100644 index 0000000..fadacc8 --- /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 Tavily 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/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/README.md b/HostedAgents/Researcher/README.md index fa8bf6b..fb817f0 100644 --- a/HostedAgents/Researcher/README.md +++ b/HostedAgents/Researcher/README.md @@ -17,6 +17,5 @@ Set before `azd ai agent run` / `azd deploy`: | `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` | | | `TAVILY_API_KEY` | yes | — | Set via `dotnet user-secrets set "TAVILY_API_KEY" ""` for local runs; use the hosted environment's secret store for deployed runs | -Instructions are sourced from `BlogWriter.Prompts.ResearcherInstructions` -(shared with the console app via a `ProjectReference` to -`../../BlogWriter.csproj`) — no local prompt duplication. +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 index 94503d6..efab9b0 100644 --- a/HostedAgents/Researcher/Researcher.HostedAgent.csproj +++ b/HostedAgents/Researcher/Researcher.HostedAgent.csproj @@ -10,12 +10,6 @@ a3f0f2b0-9c7f-4c34-9e26-3c26d2a6f2b1 - - - - - diff --git a/HostedAgents/Researcher/azure.yaml b/HostedAgents/Researcher/azure.yaml index 57f8c50..077cb47 100644 --- a/HostedAgents/Researcher/azure.yaml +++ b/HostedAgents/Researcher/azure.yaml @@ -2,30 +2,32 @@ name: blogwriter-hosted-agent-researcher metadata: - template: blogwriter-hosted-agent + 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: bin/Debug/net10.0/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 + 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} + - name: TAVILY_API_KEY + value: ${TAVILY_API_KEY} + 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/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/README.md b/HostedAgents/Reviewer/README.md index 979ff35..43dc2d8 100644 --- a/HostedAgents/Reviewer/README.md +++ b/HostedAgents/Reviewer/README.md @@ -12,6 +12,5 @@ Set before `azd ai agent run` / `azd deploy` (env vars, or via `azd env set`): | `FOUNDRY_PROJECT_ENDPOINT` | yes | — | | `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` | -Instructions are sourced from `BlogWriter.Prompts.ReviewerInstructions` -(shared with the console app via a `ProjectReference` to -`../../BlogWriter.csproj`) — no local prompt duplication. +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 index f93f420..2a6b9b3 100644 --- a/HostedAgents/Reviewer/Reviewer.HostedAgent.csproj +++ b/HostedAgents/Reviewer/Reviewer.HostedAgent.csproj @@ -10,12 +10,6 @@ de40287b-1f47-4dd0-be3f-989a3ba5c989 - - - - - diff --git a/HostedAgents/Reviewer/azure.yaml b/HostedAgents/Reviewer/azure.yaml index 4fc2a96..8fab74c 100644 --- a/HostedAgents/Reviewer/azure.yaml +++ b/HostedAgents/Reviewer/azure.yaml @@ -2,30 +2,30 @@ name: blogwriter-hosted-agent-reviewer metadata: - template: blogwriter-hosted-agent + 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: bin/Debug/net10.0/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 + 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 8bf9110..199a81b 100644 --- a/Program.cs +++ b/Program.cs @@ -1,6 +1,8 @@ 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; @@ -25,6 +27,7 @@ string GetRequired(string key) => // 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"; @@ -34,33 +37,23 @@ string GetRequired(string key) => int maxOutputTokens = int.TryParse(config["MAX_OUTPUT_TOKENS"], out int configuredMaxOutputTokens) ? configuredMaxOutputTokens : 4096; long maxTotalTokens = long.TryParse(config["MAX_TOTAL_TOKENS"], out long configuredMaxTotalTokens) ? configuredMaxTotalTokens : 40000; -// Entra ID only — no API keys, per repository constraint. One credential and -// HttpClient are shared across all 4 remote hosted-agent chat clients. -var azureCredential = new DefaultAzureCredential(); -using var hostedAgentHttpClient = new HttpClient(); - -// Builds one IChatClient per hosted agent, each still wrapped with function -// invocation, OpenTelemetry, and a shared TokenCapChatClient — identical -// middleware pipeline to the pre-migration single shared client, just fanned -// out to 4 remote transports instead of 1. -List tokenCapChatClients = []; -IChatClient BuildAgentChatClient(string hostedAgentName) +// 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 { - TokenCapChatClient? tokenCap = null; - IChatClient client = new RemoteHostedAgentChatClient(hostedAgentHttpClient, azureCredential, foundryProjectEndpoint, hostedAgentName) - .AsBuilder() - .UseFunctionInvocation() - .UseOpenTelemetry(sourceName: "BlogWriter.ChatClient") - .Use(inner => tokenCap = new TokenCapChatClient(inner, maxTotalTokens)) - .Build(); - tokenCapChatClients.Add(tokenCap!); - return client; + TenantId = tenantId, +}); +AIProjectClient projectClient = new(foundryProjectEndpoint, azureCredential); +AIAgent BuildFoundryAgent(string hostedAgentName) +{ + Uri agentEndpoint = new($"{foundryProjectEndpoint.AbsoluteUri.TrimEnd('/')}/agents/{hostedAgentName}/endpoint/protocols/openai"); + return projectClient.AsAIAgent(agentEndpoint); } -IChatClient bloggerLlm = BuildAgentChatClient(bloggerAgentName); -IChatClient researcherLlm = BuildAgentChatClient(researcherAgentName); -IChatClient authorLlm = BuildAgentChatClient(authorAgentName); -IChatClient reviewerLlm = BuildAgentChatClient(reviewerAgentName); +AIAgent bloggerLlm = BuildFoundryAgent(bloggerAgentName); +AIAgent researcherLlm = BuildFoundryAgent(researcherAgentName); +AIAgent authorLlm = BuildFoundryAgent(authorAgentName); +AIAgent reviewerLlm = BuildFoundryAgent(reviewerAgentName); var chatOptions = new ChatOptions { @@ -216,23 +209,4 @@ int ReadWordCount(string prompt, int defaultValue, int? minimum = null) } Console.WriteLine("\n=============================\n"); -// Aggregate usage across all 4 remote hosted-agent chat clients (one -// TokenCapChatClient per agent, replacing the single shared instance from -// before the migration). -long totalInput = 0, totalOutput = 0, totalReasoning = 0, totalTokens = 0; -foreach (TokenCapChatClient tokenCap in tokenCapChatClients) -{ - TokenUsageSnapshot usage = tokenCap.UsageSnapshot; - totalInput += usage.InputTokens; - totalOutput += usage.OutputTokens; - totalReasoning += usage.ReasoningTokens; - totalTokens += usage.TotalTokens; -} - -Console.WriteLine("\n========== TOKEN USAGE =========="); -Console.WriteLine($"Input tokens: {totalInput}"); -Console.WriteLine($"Output tokens: {totalOutput}"); -Console.WriteLine($"Reasoning tokens: {totalReasoning}"); -Console.WriteLine($"Total tokens: {totalTokens}"); -Console.WriteLine("=================================="); diff --git a/README.md b/README.md index 6eb3ff9..bb30432 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 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` -(`RemoteHostedAgentChatClient.cs`). +using the Microsoft Agent Framework Foundry integration. ``` BlogWriter/ (console app — orchestration only, calls hosted agents remotely) @@ -39,6 +39,7 @@ 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` | | diff --git a/RemoteHostedAgentChatClient.cs b/RemoteHostedAgentChatClient.cs deleted file mode 100644 index 3dd3a6e..0000000 --- a/RemoteHostedAgentChatClient.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Serialization; -using Azure.Core; -using Microsoft.Extensions.AI; - -namespace BlogWriter; - -/// -/// that talks to a single Azure AI Foundry Hosted -/// Agent over its OpenAI-compatible Responses protocol endpoint -/// ({project_endpoint}/agents/{name}/endpoint/protocols/openai/responses), -/// authenticated with a Microsoft Entra ID bearer token (no API keys — per the -/// project's hard constraint on Entra-only auth). -/// -/// This is a minimal, self-contained client: it posts the conversation as a -/// single input string (system/user turns concatenated) and reads back -/// output_text. It composes normally with the rest of the -/// pipeline (function invocation, OpenTelemetry, -/// ) since those only depend on the -/// abstraction, not the transport. -/// -/// Streaming is not implemented against the hosted protocol yet; it falls back -/// to a single update built from the non-streaming response. -/// -public sealed class RemoteHostedAgentChatClient : IChatClient -{ - // Default Entra ID scope for Foundry Agent Service data-plane calls. - // Confirm this against the target Foundry resource before production use — - // some deployments may require "https://cognitiveservices.azure.com/.default". - public const string DefaultScope = "https://ai.azure.com/.default"; - - private readonly HttpClient _httpClient; - private readonly TokenCredential _credential; - private readonly string _scope; - private readonly Uri _responsesEndpoint; - - /// Shared HttpClient; caller owns disposal. - /// Entra ID credential, e.g. DefaultAzureCredential. - /// Foundry project endpoint, e.g. https://<account>.services.ai.azure.com/api/projects/<project>. - /// Name of the deployed hosted agent (pre-provisioned via azd; not created at runtime). - /// Entra ID token scope; defaults to . - public RemoteHostedAgentChatClient( - HttpClient httpClient, - TokenCredential credential, - Uri projectEndpoint, - string hostedAgentName, - string scope = DefaultScope) - { - _httpClient = httpClient; - _credential = credential; - _scope = scope; - _responsesEndpoint = new Uri( - projectEndpoint, - $"agents/{hostedAgentName}/endpoint/protocols/openai/responses"); - } - - public async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - string input = BuildInput(messages); - - using HttpRequestMessage request = new(HttpMethod.Post, _responsesEndpoint) - { - Content = JsonContent.Create(new HostedAgentRequest(input, options?.MaxOutputTokens, options?.Temperature)), - }; - - AccessToken token = await _credential.GetTokenAsync(new TokenRequestContext([_scope]), cancellationToken); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); - - using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken); - response.EnsureSuccessStatusCode(); - - HostedAgentResponse? payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - string text = payload?.OutputText ?? string.Empty; - - var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, text)); - if (payload?.Usage is { } usage) - { - chatResponse.Usage = new UsageDetails - { - InputTokenCount = usage.InputTokens, - OutputTokenCount = usage.OutputTokens, - TotalTokenCount = usage.TotalTokens, - }; - } - - return chatResponse; - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // No native streaming support against the hosted Responses endpoint yet; - // surface the full response as a single update. - ChatResponse response = await GetResponseAsync(messages, options, cancellationToken); - foreach (ChatMessage message in response.Messages) - { - yield return new ChatResponseUpdate(message.Role, message.Contents); - } - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() - { - // _httpClient is owned by the caller (shared across agents); nothing to dispose here. - } - - private static string BuildInput(IEnumerable messages) - { - // Concatenate system/user turns into the single "input" string the - // hosted Responses endpoint accepts (see the /responses curl sample in - // the Foundry Hosted Agents docs). The hosted agent's own baked-in - // instructions still apply server-side; this preserves the same - // client-supplied Instructions/user-message behaviour the app relied on - // pre-migration. - return string.Join("\n\n", messages.Select(m => $"[{m.Role}] {m.Text}")); - } - - private sealed record HostedAgentRequest( - [property: JsonPropertyName("input")] string Input, - [property: JsonPropertyName("max_output_tokens")] int? MaxOutputTokens, - [property: JsonPropertyName("temperature")] float? Temperature); - - private sealed record HostedAgentResponse( - [property: JsonPropertyName("output_text")] string? OutputText, - [property: JsonPropertyName("usage")] HostedAgentUsage? Usage); - - private sealed record HostedAgentUsage( - [property: JsonPropertyName("input_tokens")] int? InputTokens, - [property: JsonPropertyName("output_tokens")] int? OutputTokens, - [property: JsonPropertyName("total_tokens")] int? TotalTokens); -} diff --git a/ResearcherAgent.cs b/ResearcherAgent.cs index fa4f769..0dc3210 100644 --- a/ResearcherAgent.cs +++ b/ResearcherAgent.cs @@ -31,28 +31,12 @@ public class ResearcherAgent : IResearcherAgent // Per-call output-token cap, applied on each RunAsync to bound cost. private readonly int? _maxOutputTokens; - public ResearcherAgent(IChatClient llm, ChatOptions chatOptions, ILogger logger) + public ResearcherAgent(AIAgent agent, ChatOptions chatOptions, ILogger logger) { _logger = logger; _maxOutputTokens = chatOptions.MaxOutputTokens; - _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, - }, - }) - .AsBuilder() - .UseOpenTelemetry(sourceName: "BlogWriter.Agents") - .Build(); + _agent = agent; _logger.LogInformation("ResearcherAgent initialized (Tavily tool runs inside the hosted Researcher agent)."); } diff --git a/ReviewerAgent.cs b/ReviewerAgent.cs index 82edd1b..5e3521e 100644 --- a/ReviewerAgent.cs +++ b/ReviewerAgent.cs @@ -25,24 +25,12 @@ public class ReviewerAgent : IReviewerAgent // 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, ChatOptions chatOptions, 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."); } From 9915e549594c62614cc850695658e79f3501436a Mon Sep 17 00:00:00 2001 From: Bruno Capuano Date: Mon, 7 Sep 2026 10:18:51 -0300 Subject: [PATCH 3/5] refactor: Simplify agent constructors and remove per-call output token cap test: Add test for shared token budget across clients chore: Update YAML configuration by removing infra provider feat: Implement cumulative token budget for MAF-hosted agents docs: Remove MAX_OUTPUT_TOKENS from README in favor of MAX_TOTAL_TOKENS --- AuthorAgent.cs | 13 +-- BlogWriter.Tests/TokenCapChatClientTests.cs | 13 +++ BloggerAgent.cs | 15 +--- HostedAgents/Blogger/azure.yaml | 2 - HostedAgents/Researcher/Program.cs | 44 ++++++---- Program.cs | 66 ++++----------- README.md | 1 - ResearcherAgent.cs | 13 +-- ReviewerAgent.cs | 13 +-- TokenCapChatClient.cs | 92 ++++++++++++++------- 10 files changed, 126 insertions(+), 146 deletions(-) diff --git a/AuthorAgent.cs b/AuthorAgent.cs index e10f24d..ee8b294 100644 --- a/AuthorAgent.cs +++ b/AuthorAgent.cs @@ -21,13 +21,9 @@ 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(AIAgent agent, ChatOptions chatOptions, ILogger logger) + public AuthorAgent(AIAgent agent, ILogger logger) { _logger = logger; - _maxOutputTokens = chatOptions.MaxOutputTokens; _agent = agent; _logger.LogInformation("AuthorAgent initialized."); @@ -57,12 +53,7 @@ public AuthorAgent(AIAgent agent, ChatOptions chatOptions, ILogger 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; if (!string.IsNullOrEmpty(content)) { diff --git a/BlogWriter.Tests/TokenCapChatClientTests.cs b/BlogWriter.Tests/TokenCapChatClientTests.cs index fd8c865..3d70df4 100644 --- a/BlogWriter.Tests/TokenCapChatClientTests.cs +++ b/BlogWriter.Tests/TokenCapChatClientTests.cs @@ -38,4 +38,17 @@ public async Task GetResponseAsync_ThrowsOnceCumulativeUsageExceedsCap() await Assert.ThrowsAsync( () => 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/BloggerAgent.cs b/BloggerAgent.cs index 3c4d030..3fde22c 100644 --- a/BloggerAgent.cs +++ b/BloggerAgent.cs @@ -30,13 +30,9 @@ 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(AIAgent agent, ChatOptions chatOptions, ILogger logger) + public BloggerAgent(AIAgent agent, ILogger logger) { _logger = logger; - _maxOutputTokens = chatOptions.MaxOutputTokens; _agent = agent; _logger.LogInformation("BloggerAgent initialized."); @@ -48,7 +44,7 @@ public BloggerAgent(IChatClient llm, ChatOptions chatOptions, ILogger 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/Blogger/azure.yaml b/HostedAgents/Blogger/azure.yaml index 37ea89a..c8b0d13 100644 --- a/HostedAgents/Blogger/azure.yaml +++ b/HostedAgents/Blogger/azure.yaml @@ -27,5 +27,3 @@ services: protocols: - protocol: responses version: 2.0.0 -infra: - provider: microsoft.foundry diff --git a/HostedAgents/Researcher/Program.cs b/HostedAgents/Researcher/Program.cs index 2545e73..9594f79 100644 --- a/HostedAgents/Researcher/Program.cs +++ b/HostedAgents/Researcher/Program.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Net.Http.Headers; using System.Net.Http.Json; using Azure.AI.AgentServer.Core; @@ -54,24 +55,35 @@ async Task PostWithRetryAsync(string requestUri, object bod } } -AIFunction tavilyTool = AIFunctionFactory.Create( - async (string query, CancellationToken cancellationToken) => +// Foundry's function-tool contract consumes only the input schema. Excluding +// the generated string return schema keeps the tool definition compatible +// while MAF still returns the search payload to the model at invocation time. +[Description("Search the web for comprehensive, accurate, and trusted results.")] +async Task SearchTavilyAsync( + [Description("The research query to search for.")] string query, + CancellationToken cancellationToken) +{ + var request = new { - var request = new - { - query, - max_results = 5, - topic = "general", - include_answer = false, - include_raw_content = false, - search_depth = "basic" - }; + 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."); + using HttpResponseMessage response = await PostWithRetryAsync("search", request, cancellationToken); + return await response.Content.ReadAsStringAsync(cancellationToken); +} + +AIFunction tavilyTool = AIFunctionFactory.Create( + SearchTavilyAsync, + new AIFunctionFactoryOptions + { + Name = "tavily_search", + ExcludeResultSchema = true, + }); // Entra ID only — no API keys, per repository constraint (this applies to the // Foundry/model auth; the Tavily key above is a third-party API key, not Foundry auth). diff --git a/Program.cs b/Program.cs index 199a81b..f1a987f 100644 --- a/Program.cs +++ b/Program.cs @@ -6,11 +6,6 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -<<<<<<< HEAD -using ModelContextProtocol.Client; -using OpenAI; -======= ->>>>>>> c05a6c3 (Add hosted agent implementations) // Secrets come from the .NET user-secrets store and from // environment variables (secrets win on key collisions). @@ -33,8 +28,7 @@ string GetRequired(string key) => string authorAgentName = config["AUTHOR_AGENT_NAME"] ?? "Author"; string reviewerAgentName = config["REVIEWER_AGENT_NAME"] ?? "Reviewer"; -// Overridable via user-secrets/env vars; these defaults match the original behaviour. -int maxOutputTokens = int.TryParse(config["MAX_OUTPUT_TOKENS"], out int configuredMaxOutputTokens) ? configuredMaxOutputTokens : 4096; +// 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; // Entra ID only — no API keys, per repository constraint. Agent Framework owns @@ -44,10 +38,15 @@ string GetRequired(string key) => TenantId = tenantId, }); AIProjectClient projectClient = new(foundryProjectEndpoint, azureCredential); +Func tokenCapFactory = TokenCapChatClient.CreateSharedFactory(maxTotalTokens); AIAgent BuildFoundryAgent(string hostedAgentName) { Uri agentEndpoint = new($"{foundryProjectEndpoint.AbsoluteUri.TrimEnd('/')}/agents/{hostedAgentName}/endpoint/protocols/openai"); - return projectClient.AsAIAgent(agentEndpoint); + return projectClient.AsAIAgent( + agentEndpoint, + tools: null, + clientFactory: tokenCapFactory, + services: null); } AIAgent bloggerLlm = BuildFoundryAgent(bloggerAgentName); @@ -55,48 +54,13 @@ AIAgent BuildFoundryAgent(string hostedAgentName) AIAgent authorLlm = BuildFoundryAgent(authorAgentName); AIAgent reviewerLlm = BuildFoundryAgent(reviewerAgentName); -var chatOptions = new ChatOptions -{ - Temperature = 1, - MaxOutputTokens = maxOutputTokens -}; - // 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."); -} -<<<<<<< HEAD -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, chatOptions, loggerFactory.CreateLogger()); -var researcherAgent = new ResearcherAgent(researcherLlm, chatOptions, loggerFactory.CreateLogger()); -var authorAgent = new AuthorAgent(authorLlm, chatOptions, loggerFactory.CreateLogger()); -var reviewerAgent = new ReviewerAgent(reviewerLlm, chatOptions, loggerFactory.CreateLogger()); ->>>>>>> c05a6c3 (Add hosted agent implementations) +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.*" @@ -198,15 +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}"); +Console.WriteLine($"\nDraft:\n{result.Draft}"); +Console.WriteLine($"\nReview Notes: {result.ReviewNotes}"); +Console.WriteLine($"Revision Number: {result.RevisionNumber}"); if (result.RevisionNumber >= ResearchState.MaxRevisions) { // 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("============================="); diff --git a/README.md b/README.md index bb30432..5e4cdbc 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ auth, no API keys: | `RESEARCHER_AGENT_NAME` | no | `Researcher` | | | `AUTHOR_AGENT_NAME` | no | `Author` | | | `REVIEWER_AGENT_NAME` | no | `Reviewer` | | -| `MAX_OUTPUT_TOKENS` | no | `4096` | Per-call cap | | `MAX_TOTAL_TOKENS` | no | `40000` | Cumulative process-wide cap (`TokenCapChatClient`) | `TAVILY_API_KEY` is no longer configured here — it now lives in the diff --git a/ResearcherAgent.cs b/ResearcherAgent.cs index 0dc3210..e4c7017 100644 --- a/ResearcherAgent.cs +++ b/ResearcherAgent.cs @@ -28,13 +28,9 @@ 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(AIAgent agent, ChatOptions chatOptions, ILogger logger) + public ResearcherAgent(AIAgent agent, ILogger logger) { _logger = logger; - _maxOutputTokens = chatOptions.MaxOutputTokens; _agent = agent; @@ -51,12 +47,7 @@ public async Task InvokeAsync(string query, CancellationToken cancellati { // 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. - // 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 5e3521e..ab7fdc8 100644 --- a/ReviewerAgent.cs +++ b/ReviewerAgent.cs @@ -22,13 +22,9 @@ 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(AIAgent agent, ChatOptions chatOptions, ILogger logger) + public ReviewerAgent(AIAgent agent, ILogger logger) { _logger = logger; - _maxOutputTokens = chatOptions.MaxOutputTokens; _agent = agent; _logger.LogInformation("ReviewerAgent initialized."); @@ -53,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); + } } } } From 5d01e11b13afe1204c57938646df5dc04df97cd8 Mon Sep 17 00:00:00 2001 From: Bruno Capuano Date: Mon, 7 Sep 2026 10:35:07 -0300 Subject: [PATCH 4/5] feat: Update Researcher agent to use Foundry-hosted web search and improve revision handling --- BlogWriter.Tests/ResearchStateTests.cs | 14 ++++ HostedAgents/README.md | 7 +- .../.agent_configs/baseline/instructions.md | 2 +- HostedAgents/Researcher/Program.cs | 79 +------------------ HostedAgents/Researcher/README.md | 7 +- HostedAgents/Researcher/azure.yaml | 2 - Program.cs | 2 +- README.md | 7 +- ResearchState.cs | 3 + ResearcherAgent.cs | 6 +- 10 files changed, 33 insertions(+), 96 deletions(-) diff --git a/BlogWriter.Tests/ResearchStateTests.cs b/BlogWriter.Tests/ResearchStateTests.cs index 769d486..45ca925 100644 --- a/BlogWriter.Tests/ResearchStateTests.cs +++ b/BlogWriter.Tests/ResearchStateTests.cs @@ -50,4 +50,18 @@ public void NeedsRevision_FalseOneStepBelowCap_TrueWhenBelow() Assert.True(belowCap.NeedsRevision); Assert.False(atCap.NeedsRevision); } + + [Theory] + [InlineData("APPROVED", ResearchState.MaxRevisions, false)] + [InlineData("Still needs work.", ResearchState.MaxRevisions, true)] + [InlineData("Still needs work.", ResearchState.MaxRevisions - 1, false)] + public void RevisionLimitReached_RequiresUnapprovedReviewAtCap( + string reviewNotes, + int revisionNumber, + bool expected) + { + var state = new ResearchState { ReviewNotes = reviewNotes, RevisionNumber = revisionNumber }; + + Assert.Equal(expected, state.RevisionLimitReached); + } } diff --git a/HostedAgents/README.md b/HostedAgents/README.md index e8f58ab..cd482dc 100644 --- a/HostedAgents/README.md +++ b/HostedAgents/README.md @@ -11,7 +11,7 @@ never builds or provisions them at runtime. | Project | Role | Notes | | --- | --- | --- | | `Blogger/` | Orchestration decisions (next step routing) | | -| `Researcher/` | Web research | Owns the Tavily search tool + its API key | +| `Researcher/` | Web research | Owns a Foundry-hosted web-search tool | | `Author/` | Drafts/revises the post | | | `Reviewer/` | Approves or requests revisions | | @@ -45,9 +45,8 @@ azd ai agent invoke "Hello!" azd ai agent monitor --follow ``` -Set `FOUNDRY_PROJECT_ENDPOINT` and `AZURE_AI_MODEL_DEPLOYMENT_NAME` (and, for -Researcher, the `TAVILY_API_KEY` user-secret) before `azd ai agent run` / -`azd deploy` — see each project's own README. +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 diff --git a/HostedAgents/Researcher/.agent_configs/baseline/instructions.md b/HostedAgents/Researcher/.agent_configs/baseline/instructions.md index fadacc8..fc9c766 100644 --- a/HostedAgents/Researcher/.agent_configs/baseline/instructions.md +++ b/HostedAgents/Researcher/.agent_configs/baseline/instructions.md @@ -1 +1 @@ -Performs current web research for technical .NET and AI blog posts using Tavily search, then summarizes credible findings. \ No newline at end of file +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/Program.cs b/HostedAgents/Researcher/Program.cs index 9594f79..78bf225 100644 --- a/HostedAgents/Researcher/Program.cs +++ b/HostedAgents/Researcher/Program.cs @@ -1,98 +1,30 @@ -using System.ComponentModel; -using System.Net.Http.Headers; -using System.Net.Http.Json; 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; -using Microsoft.Extensions.Configuration; // 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 the Tavily web-search -// tool: the tool call happens inside this hosted process, not in the main -// console app, so its HTTP client, retry logic, and TAVILY_API_KEY secret all -// live here. - -IConfiguration config = new ConfigurationBuilder() - .AddEnvironmentVariables() - .AddUserSecrets(typeof(Program).Assembly) - .Build(); +// 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"; -string tavilyApiKey = config["TAVILY_API_KEY"] - ?? throw new InvalidOperationException( - "Missing configuration value 'TAVILY_API_KEY'. Set it with: dotnet user-secrets set \"TAVILY_API_KEY\" \"\""); - -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. -// (Ported unchanged from the console app's pre-migration Program.cs.) -async Task PostWithRetryAsync(string requestUri, object body, CancellationToken cancellationToken) -{ - 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); - } - } -} - -// Foundry's function-tool contract consumes only the input schema. Excluding -// the generated string return schema keeps the tool definition compatible -// while MAF still returns the search payload to the model at invocation time. -[Description("Search the web for comprehensive, accurate, and trusted results.")] -async Task SearchTavilyAsync( - [Description("The research query to search for.")] 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); -} - -AIFunction tavilyTool = AIFunctionFactory.Create( - SearchTavilyAsync, - new AIFunctionFactoryOptions - { - Name = "tavily_search", - ExcludeResultSchema = true, - }); // Entra ID only — no API keys, per repository constraint (this applies to the -// Foundry/model auth; the Tavily key above is a third-party API key, not Foundry auth). +// Foundry model and hosted web-search authentication). AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) .AsAIAgent( model: modelDeployment, instructions: BlogWriter.Prompts.ResearcherInstructions, name: "Researcher", - tools: [tavilyTool]); + tools: [new HostedWebSearchTool()]); var builder = AgentHost.CreateBuilder(args); builder.Services.AddFoundryResponses(agent); @@ -100,6 +32,3 @@ async Task SearchTavilyAsync( var app = builder.Build(); app.Run(); - -// Needed for AddUserSecrets(typeof(Program).Assembly) with top-level statements. -internal partial class Program; diff --git a/HostedAgents/Researcher/README.md b/HostedAgents/Researcher/README.md index fb817f0..b98fcfb 100644 --- a/HostedAgents/Researcher/README.md +++ b/HostedAgents/Researcher/README.md @@ -3,9 +3,8 @@ 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 the **Tavily web-search -tool**: the tool call, its `HttpClient`, and retry logic run inside this -hosted process (see `Program.cs`), not in the console app. +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 @@ -15,7 +14,5 @@ Set before `azd ai agent run` / `azd deploy`: | --- | --- | --- | --- | | `FOUNDRY_PROJECT_ENDPOINT` | yes | — | Entra ID auth (`DefaultAzureCredential`) | | `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` | | -| `TAVILY_API_KEY` | yes | — | Set via `dotnet user-secrets set "TAVILY_API_KEY" ""` for local runs; use the hosted environment's secret store for deployed runs | - 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/azure.yaml b/HostedAgents/Researcher/azure.yaml index 077cb47..055ee13 100644 --- a/HostedAgents/Researcher/azure.yaml +++ b/HostedAgents/Researcher/azure.yaml @@ -26,8 +26,6 @@ services: environmentVariables: - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} - - name: TAVILY_API_KEY - value: ${TAVILY_API_KEY} protocols: - protocol: responses version: 2.0.0 diff --git a/Program.cs b/Program.cs index f1a987f..2c53e93 100644 --- a/Program.cs +++ b/Program.cs @@ -165,7 +165,7 @@ int ReadWordCount(string prompt, int defaultValue, int? minimum = null) Console.WriteLine($"\nDraft:\n{result.Draft}"); Console.WriteLine($"\nReview Notes: {result.ReviewNotes}"); Console.WriteLine($"Revision Number: {result.RevisionNumber}"); -if (result.RevisionNumber >= ResearchState.MaxRevisions) +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. diff --git a/README.md b/README.md index 5e4cdbc..a71fc69 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ 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 the Tavily web-search tool) + Researcher/ (Foundry Hosted Agent — owns hosted web search) Author/ (Foundry Hosted Agent — drafts/revises the post) Reviewer/ (Foundry Hosted Agent — approves or requests revisions) ``` @@ -46,11 +46,8 @@ auth, no API keys: | `REVIEWER_AGENT_NAME` | no | `Reviewer` | | | `MAX_TOTAL_TOKENS` | no | `40000` | Cumulative process-wide cap (`TokenCapChatClient`) | -`TAVILY_API_KEY` is no longer configured here — it now lives in the -Researcher hosted agent's own configuration (see `HostedAgents/Researcher/README.md`). - ## Miscellaneous Notes -* Tavily web search now runs **inside the hosted Researcher agent** — the tool call, HTTP client, and retry logic live in `HostedAgents/Researcher/Program.cs`. +* 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. 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 e4c7017..05f41ff 100644 --- a/ResearcherAgent.cs +++ b/ResearcherAgent.cs @@ -9,7 +9,7 @@ namespace BlogWriter; /// Performs research tasks with a and returns /// concise findings. /// -/// The Tavily web-search tool now runs inside the Researcher Foundry Hosted +/// 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 @@ -34,7 +34,7 @@ public ResearcherAgent(AIAgent agent, ILogger logger) _agent = agent; - _logger.LogInformation("ResearcherAgent initialized (Tavily tool runs inside the hosted Researcher agent)."); + _logger.LogInformation("ResearcherAgent initialized (web search runs inside the hosted Researcher agent)."); } /// Execute research by letting the agent search and summarise. @@ -45,7 +45,7 @@ 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. AgentResponse response = await _agent.RunAsync(query, cancellationToken: cancellationToken); string summary = response.Text; From 0b87761bc21d1c691b15c1469a5a07a4f422d95f Mon Sep 17 00:00:00 2001 From: Bruno Capuano Date: Mon, 7 Sep 2026 10:39:45 -0300 Subject: [PATCH 5/5] docs: add architecture, v1-to-v2 changelog, deployment, and configuration guides --- README.md | 6 +++ docs/architecture.md | 87 ++++++++++++++++++++++++++++++++++++++ docs/changelog-v1-to-v2.md | 53 +++++++++++++++++++++++ docs/configuration.md | 43 +++++++++++++++++++ docs/deployment.md | 82 +++++++++++++++++++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 docs/architecture.md create mode 100644 docs/changelog-v1-to-v2.md create mode 100644 docs/configuration.md create mode 100644 docs/deployment.md diff --git a/README.md b/README.md index a71fc69..55ea906 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ auth, no API keys: | `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 * 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`. 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).