From 518e90e1140852eb70b635736b5c029b5eb21fe7 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:05:47 +0100 Subject: [PATCH] Add Dockerfiles, README, agent yamls and bearer token support to Hosted-Workflow-Handoff - Add Dockerfile and Dockerfile.contributor for Docker-based testing - Add agent.yaml and agent.manifest.yaml with triage-workflow as primary agent - Add README.md following sibling pattern, noting Azure OpenAI vs Foundry endpoint - Add DevTemporaryTokenCredential and ChainedTokenCredential for Docker auth - Register triage-workflow as non-keyed default so azd invoke works without model - Update .env.example with AZURE_BEARER_TOKEN sentinel - Add .gitignore to 04-hosting to suppress VS-generated launchSettings.json - Fix docker run image name in Hosted-Workflow-Simple README --- dotnet/samples/04-hosting/.gitignore | 1 + .../Hosted-Workflow-Handoff/.env.example | 1 + .../Hosted-Workflow-Handoff/Dockerfile | 17 +++ .../Dockerfile.contributor | 19 +++ .../Hosted-Workflow-Handoff/Program.cs | 43 +++++- .../Hosted-Workflow-Handoff/README.md | 126 ++++++++++++++++++ .../agent.manifest.yaml | 30 +++++ .../Hosted-Workflow-Handoff/agent.yaml | 9 ++ .../Hosted-Workflow-Simple/README.md | 2 +- 9 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 dotnet/samples/04-hosting/.gitignore create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml diff --git a/dotnet/samples/04-hosting/.gitignore b/dotnet/samples/04-hosting/.gitignore new file mode 100644 index 0000000000..324c8dcfb3 --- /dev/null +++ b/dotnet/samples/04-hosting/.gitignore @@ -0,0 +1 @@ +**/Properties/launchSettings.json diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/.env.example index 0f711561be..bfb3c97208 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/.env.example @@ -1,4 +1,5 @@ AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ AZURE_OPENAI_DEPLOYMENT=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile new file mode 100644 index 0000000000..14b356ad98 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedWorkflowHandoff.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile.contributor new file mode 100644 index 0000000000..4cc047c8bc --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-workflow-handoff . +# docker run --rm -p 8088:8088 -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-workflow-handoff +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedWorkflowHandoff.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs index 6b2eafddfd..9783aca8f3 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs @@ -17,6 +17,7 @@ using System.ComponentModel; using Azure.AI.OpenAI; +using Azure.Core; using Azure.Identity; using DotNetEnv; using Microsoft.Agents.AI; @@ -37,7 +38,9 @@ var builder = WebApplication.CreateBuilder(args); var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.")); var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o"; -var azureClient = new AzureOpenAIClient(endpoint, new DefaultAzureCredential()); +var azureClient = new AzureOpenAIClient(endpoint, new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential())); IChatClient chatClient = azureClient.GetResponsesClient().AsIChatClient(deployment); // --------------------------------------------------------------------------- @@ -109,6 +112,10 @@ Workflow triageWorkflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triageAg builder.AddAIAgent("triage-workflow", (_, key) => triageWorkflow.AsAIAgent(name: key)); +// Register triage-workflow as the non-keyed default so azd invoke (no model) works +builder.Services.AddSingleton(sp => + sp.GetRequiredKeyedService("triage-workflow")); + // --------------------------------------------------------------------------- // 4. Wire up the agent-framework handler and Responses Server SDK // --------------------------------------------------------------------------- @@ -150,6 +157,13 @@ app.Run(); // Local tool definitions // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Dev-only credential: reads a pre-fetched bearer token from AZURE_BEARER_TOKEN. +// When the value is missing or set to "DefaultAzureCredential", this credential +// throws CredentialUnavailableException so the ChainedTokenCredential falls +// through to DefaultAzureCredential. +// --------------------------------------------------------------------------- + [Description("Gets the current date and time in the specified timezone.")] static string GetCurrentTime( [Description("IANA timezone (e.g. 'America/New_York', 'Europe/London', 'UTC'). Defaults to UTC.")] @@ -178,3 +192,30 @@ static string GetWeather( var condition = conditions[rng.Next(conditions.Length)]; return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h."; } + +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => this.GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(this.GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md new file mode 100644 index 0000000000..643af74551 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md @@ -0,0 +1,126 @@ +# Hosted-Workflow-Handoff + +A hosted agent server demonstrating two patterns in a single app: + +- **`tool-agent`** — an agent with local tools (time, weather) plus remote Microsoft Learn MCP tools +- **`triage-workflow`** — a handoff workflow that routes conversations to specialist agents (code expert or creative writer) using `AgentWorkflowBuilder` + +Both agents are served over the Responses protocol. The server also exposes interactive web demos at `/tool-demo` and `/workflow-demo`. + +> Unlike the other samples in this folder, this one connects to an **Azure OpenAI** resource directly (not an Azure AI Foundry project endpoint). + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure OpenAI resource with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your values: + +```bash +cp .env.example .env +``` + +Edit `.env`: + +```env +AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ +AZURE_OPENAI_DEPLOYMENT=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +``` + +`AZURE_BEARER_TOKEN=DefaultAzureCredential` is a sentinel value that tells the app to skip the bearer token and fall through to `DefaultAzureCredential` (requires `az login`). Set it to a real token only when running in Docker. + +> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff +dotnet run +``` + +The server starts on `http://localhost:8088`. Open `http://localhost:8088` to see the demo index page. + +### Test it + +Using the Azure Developer CLI (invokes `triage-workflow` — the primary/default agent): + +```bash +azd ai agent invoke --local "Write me a short poem about coding" +``` + +To target a specific agent by name, use curl: + +```bash +# Invoke triage-workflow explicitly +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Write me a haiku about autumn", "model": "triage-workflow"}' +``` + +```bash +# Invoke tool-agent (local tools + MCP) +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "What time is it in Tokyo?", "model": "tool-agent"}' +``` + +## Running with Docker + +### 1. Publish for the container runtime + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-workflow-handoff . +``` + +### 3. Run the container + +```bash +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +docker run --rm -p 8088:8088 \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-workflow-handoff +``` + +### 4. Test it + +```bash +azd ai agent invoke --local "Explain async/await in C#" +``` + +## How the triage workflow works + +``` +User message + │ + ▼ +┌──────────────┐ +│ Triage Agent │ ──routes──▶ ┌─────────────┐ +│ (router) │ │ Code Expert │ +└──────────────┘ └─────────────┘ + ▲ │ + │◀──────────────────────────────┘ + │ + └──routes──▶ ┌─────────────────┐ + │ Creative Writer │ + └─────────────────┘ +``` + +The triage agent receives every message and hands off to the appropriate specialist. Specialists route back to the triage agent after responding, allowing for multi-turn conversations. + +## NuGet package users + +Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowHandoff.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml new file mode 100644 index 0000000000..7909463901 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: triage-workflow +displayName: "Triage Handoff Workflow Agent" + +description: > + A hosted agent demonstrating two patterns in a single server: a tool-equipped agent + with local tools and remote MCP tools, and a triage workflow that routes conversations + to specialist agents (code expert or creative writer) via handoff orchestration. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Workflows + - Handoff + - Agent Framework + +template: + name: triage-workflow + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml new file mode 100644 index 0000000000..6b192c4eb6 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: triage-workflow +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md index 7312df31de..0cd438f5f1 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md @@ -77,7 +77,7 @@ docker run --rm -p 8088:8088 \ -e AGENT_NAME=hosted-workflow-simple \ -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ --env-file .env \ - hosted-workflows + hosted-workflow-simple ``` ### 4. Test it