diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 325bfee86c9..1b6e1b33cfd 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -341,6 +341,9 @@ + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md index e57b90201d0..b68f7c3a87d 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md @@ -205,3 +205,17 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedAzureSearchRag.csproj` for the `PackageReference` alternative. + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/.dockerignore b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/.dockerignore new file mode 100644 index 00000000000..6caa228241c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/.dockerignore @@ -0,0 +1,20 @@ +# Keeps local-only files out of the image build context. Without this, `COPY . .` in the Dockerfile +# would copy the local .env into a build layer, so local credentials would ship inside the image. +.env +.env.* +.azure/ +.git/ + +# Build output: the image builds from source, so shipping host binaries only bloats the context and +# risks copying binaries built for a different platform into the container. +bin/ +obj/ +*.user +*.suo +.vs/ + +# Agent session state written during local runs. +.checkpoints/ + +# Note: local-feed/ and nuget.config are deliberately NOT excluded. When present (contributor mode) +# the `dotnet restore` inside the image build resolves the Agent Framework from them. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/.env.example new file mode 100644 index 00000000000..77c6b4cfa36 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/.env.example @@ -0,0 +1,17 @@ +# Foundry project endpoint (shape: https:///api/projects/) +FOUNDRY_PROJECT_ENDPOINT= + +# Model deployment name in your Foundry project. +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o + +# Local development only. Bind the app to the port Foundry probes for readiness, which is the +# port the Using-Samples REPLs expect. The Dockerfile sets this for the container; a plain +# `dotnet run` on the host does not go through the Dockerfile, so set it here too. +ASPNETCORE_URLS=http://+:8088 + +# Local development only. Restrict DefaultAzureCredential to developer credentials +# (Azure CLI, Visual Studio, azd) and skip the Managed Identity probe. Without this, +# on a machine with no managed identity DefaultAzureCredential hangs for a long time +# probing the IMDS endpoint (169.254.169.254) before every model call. Not set in +# Foundry, where the platform-injected managed identity is used. +AZURE_TOKEN_CREDENTIALS=dev diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/Dockerfile new file mode 100644 index 00000000000..e8681bb4e35 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/Dockerfile @@ -0,0 +1,19 @@ +# Foundry builds this image and runs it as the hosted agent. The build restores and publishes the +# project inside the container, so a contributor feed dropped into this folder (local-feed/ plus +# nuget.config) is picked up by the `dotnet restore` below without any change here. +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=build /app/publish . + +# Foundry probes port 8088 for readiness. The .NET base image defaults ASPNETCORE_URLS to port 80, +# so without this the probe never succeeds and every invoke fails with HTTP 424 session_not_ready. +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 + +ENTRYPOINT ["dotnet", "HostedChatClientAgentDocker.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/HostedChatClientAgentDocker.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/HostedChatClientAgentDocker.csproj new file mode 100644 index 00000000000..31a65fd0b7a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/HostedChatClientAgentDocker.csproj @@ -0,0 +1,46 @@ + + + + + + false + + + + + + + net10.0 + + enable + enable + HostedChatClientAgentDocker + HostedChatClientAgentDocker + 7b1c3f04-24a1-4f0e-9a5e-0d2f6b8c1e57 + 1.15.0-preview.260722.1 + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/Program.cs new file mode 100644 index 00000000000..2461d871dcb --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/Program.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Sample: a minimal general-purpose AI assistant hosted as a Foundry Hosted Agent +// using the Responses protocol. It is deployed to Foundry as a container image built +// from the Dockerfile in this folder. +// +// The sibling Hosted-ChatClientAgent sample is the same agent deployed the other way, +// straight from source with no container image. Compare the two folders to see exactly +// what the container path adds. + +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; + +// Load a local .env file when present (local development only). In Foundry the +// platform injects the required environment variables at runtime. +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(System.Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); + +// Environment variables can arrive set but blank: azd substitutes an empty string when the azd +// environment does not define the variable referenced from azure.yaml. An empty string is not +// null, so a plain ?? chain would pass the blank straight through and fail deep inside the SDK. +var model = FirstNonBlank( + System.Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME"), + System.Environment.GetEnvironmentVariable("FOUNDRY_MODEL"), + "gpt-4o"); + +var agentName = System.Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-agent-docker"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful +// consideration in production. Consider a specific credential (for example +// ManagedIdentityCredential) to avoid latency, unintended credential probing, and +// fallback security risks. +AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) + .AsAIAgent( + model: model, + instructions: """ + You are a helpful AI assistant hosted as a Foundry Hosted Agent. + You can help with a wide range of tasks including answering questions, + providing explanations, brainstorming ideas, and offering guidance. + Be concise, clear, and helpful in your responses. + """, + name: agentName, + description: "A simple general-purpose AI assistant"); + +// Host the agent using the Responses protocol. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +app.Run(); + +// Returns the first candidate that has an actual value, ignoring null and blank entries. +static string FirstNonBlank(params string?[] candidates) => + Array.Find(candidates, c => !string.IsNullOrWhiteSpace(c))!; diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/README.md new file mode 100644 index 00000000000..048302530fc --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/README.md @@ -0,0 +1,327 @@ +# Hosted-ChatClientAgent-Dockerfile + +A minimal general-purpose AI assistant hosted as a Foundry Hosted Agent using the Responses protocol. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served with `AddFoundryResponses` / `MapFoundryResponses`. + +This sample deploys to Foundry as a **container image** built from the `Dockerfile` in this folder. + +The sibling [`Hosted-ChatClientAgent`](../Hosted-ChatClientAgent/) sample is the same agent deployed the other way, straight from source with no container image. That is the default for .NET and needs no Docker, so prefer it unless you need control over the runtime image. + +| | Source (ZIP) | Container (this sample) | +|---|---|---| +| Deploy mode | `code`, the default for .NET | `container`, opt in with `--deploy-mode container` | +| Extra files | none | `Dockerfile`, `.dockerignore` | +| Who builds | Foundry runs `dotnet restore` + `dotnet publish` on the upload | Foundry builds the `Dockerfile` | +| Docker required | no | no, `azd` builds remotely in Azure Container Registry | +| Listen port | the package binds it, or `env` in `azure.yaml` | `ENV ASPNETCORE_URLS` in the `Dockerfile` | +| Extra Azure resource | none | an Azure Container Registry | + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An **existing** Foundry project with an **existing** model deployment (for example `gpt-4o`). + This sample's `azure.yaml` declares no `deployments:` block, so `azd` connects to a project and + a deployment you already have rather than creating them. `azd ai agent init` prompts you to pick + the project, and takes the deployment name as the `-d` argument. +- Azure CLI logged in (`az login`) +- Azure Developer CLI (`azd`) with the AI agents extension: `azd extension install azure.ai.agents` +- Docker Desktop **only** if you switch to local image builds by setting `remoteBuild: false` under + the `docker:` block in `azure.yaml`. By default `azd` builds the image in Azure Container + Registry, so no local Docker is needed. + +## Files + +| File | Purpose | +|------|---------| +| `Program.cs` | The agent: builds the agent, hosts it with the Responses protocol. | +| `Dockerfile` | Builds the image Foundry runs. Restores and publishes the project inside the container, and pins the listen port to 8088. | +| `.dockerignore` | Keeps local-only files (notably `.env`) out of the image build context. | +| `azure.yaml` | The unified `azd` project file. Declares the Foundry project and the hosted agent with `language: docker` and no `codeConfiguration`, which is what selects the container path. | +| `HostedChatClientAgentDocker.csproj` | Self-contained project: single target framework and explicit package versions. It also opts out of the repository's central package management, which does not travel inside the image build context. | +| `.env.example` | Template for local configuration. | + +## Configuration + +Copy the template and fill in your project endpoint: + +PowerShell: + +```powershell +copy .env.example .env +``` + +Bash: + +```bash +cp .env.example .env +``` + +```env +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +ASPNETCORE_URLS=http://+:8088 +AZURE_TOKEN_CREDENTIALS=dev +``` + +> `.env` is gitignored, and `.dockerignore` keeps it out of the image. The `.env.example` template +> is checked in as a reference. + +> **Windows note:** write `.env` as UTF-8 **without** a byte order mark. `azd` reads the file +> during `azd ai agent init` and fails with `unexpected character "»" in variable name` when a mark +> is present. PowerShell's `Set-Content -Encoding UTF8BOM` adds one; use `-Encoding utf8NoBOM`. + +> **Local development on a machine without a managed identity:** set `AZURE_TOKEN_CREDENTIALS=dev`. +> `Program.cs` authenticates with `DefaultAzureCredential` (the pattern the hosted platform +> expects, where a managed identity is injected). On a developer machine with no managed identity, +> `DefaultAzureCredential` probes the Azure Instance Metadata Service (IMDS, `169.254.169.254`) and +> blocks for a long time on the network timeout before every model call, so requests appear to +> hang. Setting `AZURE_TOKEN_CREDENTIALS=dev` restricts `DefaultAzureCredential` to developer +> credentials (Azure CLI, Visual Studio, `azd`) and skips the managed-identity probe. This variable +> is only for local runs; the deployed agent in Foundry uses the platform-injected managed identity. + +## Run and test locally + +Local runs use two terminals: one hosts the agent, the other is a code-first client that talks to it +using Agent Framework components, see the sibling [`Using-Samples`](../Using-Samples/) REPLs. + +**Terminal 1 — host the agent:** + +``` +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile +az login +dotnet run +``` + +The agent starts on `http://localhost:8088`. + +**Terminal 2 — chat with it (code-first REPL):** + +PowerShell: + +```powershell +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent +$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent-docker" +dotnet run -- --local +``` + +Bash: + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent +export AZURE_AI_AGENT_NAME="hosted-chat-client-agent-docker" +dotnet run -- --local +``` + +To exercise the image instead of the host build, build and run the container directly: + +``` +docker build -t hosted-chat-client-agent-docker . +docker run --rm -p 8088:8088 --env-file .env hosted-chat-client-agent-docker +``` + +## Deploy to Foundry (container) + +`azd` scaffolds the project into a working folder, so every step below runs from an **empty +directory outside the repository**, and `-m` points at this sample's `azure.yaml`. + +### Step 1: create the working directory and enter it + +PowerShell: + +```powershell +$work = Join-Path $env:TEMP "hosted-chat-docker-work" +mkdir $work +cd $work +``` + +Bash: + +```bash +WORK="${TMPDIR:-/tmp}/hosted-chat-docker-work" +mkdir -p "$WORK" +cd "$WORK" +``` + +### Step 2: scaffold the project + +`--deploy-mode container` is the argument that selects the container path. Without it `azd` +defaults to `code` for .NET, which ignores the `Dockerfile` and deploys the source as a ZIP. + +`azd ai agent init` copies the sample into a subfolder named after the top-level `name:` in +`azure.yaml`, which is `hosted-chat-client-agent-docker`. It also writes the adopted `azure.yaml` +and the `azd` environment there. + +`azd ai agent init` prompts you to pick the Foundry project, so no project argument is needed. +`-d` is the name of an existing model deployment in that project; omit it and `azd` prompts for +that too. + +> Pick an **existing** project at the prompt. The prompt needs an interactive terminal: run +> non-interactively (in CI, for example) and `azd` skips it and provisions a brand new Foundry +> project and resource group instead. Pass `-p ` when you need that to be +> unattended. + +`azure.yaml` passes the model deployment to the container by reading it from the `azd` environment. +Confirm it landed there, and set it yourself if it did not: + +``` +azd env get-values +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME +``` + +PowerShell: + +```powershell +$sample = "/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/azure.yaml" + +azd auth login +azd ai agent init -m $sample -d --deploy-mode container +``` + +Bash: + +```bash +SAMPLE="/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/azure.yaml" + +azd auth login +azd ai agent init -m "$SAMPLE" -d --deploy-mode container +``` + +### Step 3: provision and deploy + +Contributors: if you are changing the Agent Framework source in this repository and want the +deployed agent to run **your** build rather than the published packages, do the extra step in +[Deploy your local framework changes](#deploy-your-local-framework-changes-contributors) now, +before the commands below. Everyone else can ignore it. + +``` +cd hosted-chat-client-agent-docker +azd provision +azd deploy +azd ai agent invoke "Hello!" +``` + +`azd provision` creates the Azure Container Registry the image is pushed to, alongside the rest of +the environment. `azd deploy` builds the image (remotely in that registry by default), pushes it, +and creates the agent version. + +To build the image on your own machine instead, flip `remoteBuild` to `false` in `azure.yaml`: + +```yaml + docker: + remoteBuild: false +``` + +That requires Docker Desktop, and on Apple Silicon or other ARM machines you must produce an +x86_64 image, since the hosting platform only runs `linux/amd64`. + +You can also test the deployed agent with the REPL: + +PowerShell: + +```powershell +cd /dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent +$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent-docker" +dotnet run -- --remote +``` + +Bash: + +```bash +cd /dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export AZURE_AI_AGENT_NAME="hosted-chat-client-agent-docker" +dotnet run -- --remote +``` + +### Step 4: clean up + +``` +azd down +``` + +Then delete the working directory. + +## Deploy your local framework changes (contributors) + +**Skip this section unless you are changing the Agent Framework itself.** Everything above is the +complete flow for using the sample. This section only applies when you are working on the framework +source in this repository, or when you otherwise need a build of it that is not published on +nuget.org. + +The reason it exists: the project restores the **published** Agent Framework packages, and the +`dotnet restore` inside the image build pulls them from nuget.org. So editing framework source in +this repository changes nothing about the deployed agent, no matter how many times you rebuild +locally. The image build context is self-contained and knows nothing about your working tree. + +The extra step packs your local framework source into NuGet packages and puts them **inside the +build context**, together with a `nuget.config` that points the restore at them. The restore inside +the image build then resolves the framework from the packages you shipped instead of from +nuget.org. + +Run it in the flow above, **between step 2 and step 3**. Nothing else changes, and it is the same +script the source-deploy sample uses: the `Dockerfile` copies the whole folder before restoring, so +a feed dropped in this folder is picked up with no change to the `Dockerfile`. + +Run it from `$work`, the working directory created in step 1, which now holds the +`hosted-chat-client-agent-docker` folder that `azd ai agent init` scaffolded: + +PowerShell: + +```powershell +cd $work +/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 -Path ./hosted-chat-client-agent-docker +``` + +Bash: + +```bash +cd "$WORK" +/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh ./hosted-chat-client-agent-docker +``` + +Then continue with step 3. The path argument is optional: called without it, the script uses the +current directory, so you can also run it from inside `hosted-chat-client-agent-docker`. + +The script changes three things in the scaffolded folder: + +| Change | Detail | +|--------|--------| +| Creates `local-feed/` | The Agent Framework packed from your local source, stamped with a version like `1.15.0-preview-local.` | +| Creates `nuget.config` | Resolves `Microsoft.Agents.AI*` from that folder and everything else from nuget.org | +| Edits the `.csproj` | Repoints its `AgentFrameworkVersion` property at the version just packed | + +Neither generated file is excluded by `.dockerignore`, so both reach the image build context and +the restore inside the build uses them. The scaffolded folder is a throwaway copy, so the +repository is left untouched. + +Two details worth knowing: + +- The version carries a timestamp because NuGet caches by package id and version. Reusing a version + would silently restore the previously packed bits instead of the build you just made. +- The whole package closure is packed, not just the two packages the sample references. Packing + only the leaf packages lets NuGet fill the rest from nuget.org, mixing a published core with a + locally built host, which fails to compile. + +Before spending a deploy, build the scaffolded folder locally. A restore problem surfaces in +seconds instead of after the image build: + +``` +cd hosted-chat-client-agent-docker +dotnet build -c Debug --tl:off +``` + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. + +For the full hosted-agent deployment guide, see the [official container deployment doc](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/azure.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/azure.yaml new file mode 100644 index 00000000000..c0f45b87026 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/azure.yaml @@ -0,0 +1,44 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: hosted-chat-client-agent-docker +services: + ai-project: + host: azure.ai.project + hosted-chat-client-agent-docker: + project: . + host: azure.ai.agent + # `docker` selects the container build path: Foundry builds the Dockerfile in this folder + # instead of restoring a plain source folder. There is no codeConfiguration block here, + # which is what tells the tooling this is a container deploy rather than a source deploy. + language: docker + docker: + # azd builds the image in Azure Container Registry by default. Set this to false to + # build on your own machine instead, which requires Docker Desktop. + remoteBuild: true + uses: + - ai-project + # ${AZURE_AI_MODEL_DEPLOYMENT_NAME} reads the model deployment `azd ai agent init` recorded + # in the active azd environment. Without it the container falls back to the default model + # name hardcoded in Program.cs, which may not exist in the target project. + # + # The listen port is not set here: the Dockerfile already sets ASPNETCORE_URLS. + env: + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + container: + resources: + cpu: "0.5" + memory: 1Gi + description: | + A simple general-purpose AI assistant hosted as a Foundry Hosted Agent, deployed as a container image. + kind: hosted + metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming + - Agent Framework + name: hosted-chat-client-agent-docker + protocols: + - protocol: responses + version: 2.0.0 diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.agentignore b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.agentignore new file mode 100644 index 00000000000..7f9f197c84f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.agentignore @@ -0,0 +1,30 @@ +# Controls which files are excluded from the code-deploy ZIP upload (.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 +azure.yaml +.agentignore + +# Security / secrets +.env +.env.* +.azure/ +.git/ + +# .NET build output +bin/ +obj/ +*.user +*.suo +.vs/ + +# Agent session state written by FileSystemAgentSessionStore during local runs. The hosted +# runtime writes its own under the container's home directory, so uploading the local copy +# would ship stale sessions with the agent. +.checkpoints/ + +# Contributor mode (scripts/Add-LocalFrameworkFeed.ps1) generates local-feed/ and nuget.config. +# Those are deliberately NOT excluded: the server-side restore needs them to resolve the Agent +# Framework from the packages shipped in this upload instead of nuget.org. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example index 99a2f75c03c..1677d79d830 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example @@ -1,6 +1,17 @@ +# Foundry project endpoint (shape: https:///api/projects/) FOUNDRY_PROJECT_ENDPOINT= + +# Model deployment name in your Foundry project. +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o + +# Local development only. Bind the app to the port Foundry probes for readiness, which is the +# port the Using-Samples REPLs expect. Recent Microsoft.Agents.AI.Foundry.Hosting versions bind +# it themselves, so this only matters while the project is pinned to an older published package. ASPNETCORE_URLS=http://+:8088 -ASPNETCORE_ENVIRONMENT=Development -FOUNDRY_MODEL=gpt-4o -AGENT_NAME=hosted-chat-client-agent -AZURE_BEARER_TOKEN=DefaultAzureCredential + +# Local development only. Restrict DefaultAzureCredential to developer credentials +# (Azure CLI, Visual Studio, azd) and skip the Managed Identity probe. Without this, +# on a machine with no managed identity DefaultAzureCredential hangs for a long time +# probing the IMDS endpoint (169.254.169.254) before every model call. Not set in +# Foundry, where the platform-injected managed identity is used. +AZURE_TOKEN_CREDENTIALS=dev diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile deleted file mode 100644 index 6f1be8ee8e2..00000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# 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", "HostedChatClientAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile.contributor deleted file mode 100644 index 200f674bdd0..00000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile.contributor +++ /dev/null @@ -1,19 +0,0 @@ -# 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-chat-client-agent . -# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-chat-client-agent --env-file .env hosted-chat-client-agent -# -# 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", "HostedChatClientAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj index 1cd4c33e746..4fe1a8567bd 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj @@ -1,33 +1,47 @@ - + + + + + + false + + + - net10.0 + + net10.0 + enable enable - false HostedChatClientAgent HostedChatClientAgent - $(NoWarn); + 222d2622-da26-4da0-99b4-0507fb8d41b0 + 1.15.0-preview.260722.1 - + + + + + - - - - - - - - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs index 7e074ca02ba..9b2484b19fe 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs @@ -1,36 +1,39 @@ // Copyright (c) Microsoft. All rights reserved. +// Sample: a minimal general-purpose AI assistant hosted as a Foundry Hosted Agent +// using the Responses protocol. It is deployed to Foundry directly from source +// (code / ZIP upload), so the platform builds and runs it with no container image. + using Azure.AI.Projects; -using Azure.Core; using Azure.Identity; using DotNetEnv; -using Hosted_Shared_Contributor_Setup; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Foundry.Hosting; -// Load .env file if present (for local development) +// Load a local .env file when present (local development only). In Foundry the +// platform injects the required environment variables at runtime. Env.TraversePath().Load(); -var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") +var projectEndpoint = new Uri(System.Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); -var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-agent"; - -var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; - -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -// Use a chained credential: try a temporary dev token first (for local Docker debugging), -// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity running in foundry). -TokenCredential credential = new ChainedTokenCredential( - new DevTemporaryTokenCredential(), - new DefaultAzureCredential()); - -// Create the agent via the AI project client using the Responses API. -AIAgent agent = new AIProjectClient(projectEndpoint, credential) +// Environment variables can arrive set but blank: azd substitutes an empty string when the azd +// environment does not define the variable referenced from azure.yaml. An empty string is not +// null, so a plain ?? chain would pass the blank straight through and fail deep inside the SDK. +var model = FirstNonBlank( + System.Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME"), + System.Environment.GetEnvironmentVariable("FOUNDRY_MODEL"), + "gpt-4o"); + +var agentName = System.Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-agent"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful +// consideration in production. Consider a specific credential (for example +// ManagedIdentityCredential) to avoid latency, unintended credential probing, and +// fallback security risks. +AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) .AsAIAgent( - model: deployment, + model: model, instructions: """ You are a helpful AI assistant hosted as a Foundry Hosted Agent. You can help with a wide range of tasks including answering questions, @@ -40,16 +43,15 @@ You are a helpful AI assistant hosted as a Foundry Hosted Agent. name: agentName, description: "A simple general-purpose AI assistant"); -// Host the agent as a Foundry Hosted Agent using the Responses API. +// Host the agent using the Responses protocol. var builder = WebApplication.CreateBuilder(args); builder.Services.AddFoundryResponses(agent); var app = builder.Build(); app.MapFoundryResponses(); -// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses -// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint). -// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path. -app.MapDevTemporaryLocalAgentEndpoint(); - app.Run(); + +// Returns the first candidate that has an actual value, ignoring null and blank entries. +static string FirstNonBlank(params string?[] candidates) => + Array.Find(candidates, c => !string.IsNullOrWhiteSpace(c))!; diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md index 9a91909177a..2c14251a87a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md @@ -1,135 +1,303 @@ -# Hosted-ChatClientAgent +# Hosted-ChatClientAgent -A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the Agent Framework instance hosting pattern. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served using the Responses protocol. +A minimal general-purpose AI assistant hosted as a Foundry Hosted Agent using the Responses protocol. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served with `AddFoundryResponses` / `MapFoundryResponses`. + +This sample deploys to Foundry **directly from source (code / ZIP upload)**: the platform builds and runs your code with no container image, so there is no Dockerfile to author or container registry to manage. + +The sibling [`Hosted-ChatClientAgent-Dockerfile`](../Hosted-ChatClientAgent-Dockerfile/) sample is the same agent deployed the other way, as a container image built from a `Dockerfile`. Source deploy is the default for .NET, so start here and switch only if you need control over the runtime image. ## Prerequisites - [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) -- A Foundry project with a deployed model (e.g., `gpt-4o`) +- An **existing** Foundry project with an **existing** model deployment (for example `gpt-4o`). + This sample's `azure.yaml` declares no `deployments:` block, so `azd` connects to a project and + a deployment you already have rather than creating them. `azd ai agent init` prompts you to pick + the project, and takes the deployment name as the `-d` argument. - Azure CLI logged in (`az login`) +- Azure Developer CLI (`azd`) with the AI agents extension: `azd extension install azure.ai.agents` + +## Files + +| File | Purpose | +|------|---------| +| `Program.cs` | The agent: builds the agent, hosts it with the Responses protocol. | +| `azure.yaml` | The unified `azd` project file. Declares the Foundry project and the hosted agent with `codeConfiguration` (source/ZIP deploy), and passes the listen port and the model deployment name to the container through `env`. | +| `.agentignore` | Controls which files are excluded from the code-deploy ZIP upload (`.gitignore` syntax). | +| `HostedChatClientAgent.csproj` | Self-contained project: single target framework and explicit package versions. It also opts out of the repository's central package management, which does not travel inside the ZIP. | +| `.env.example` | Template for local configuration. | +| `../../scripts/Add-LocalFrameworkFeed.ps1`, `../../scripts/add-local-framework-feed.sh` | Contributor-only helpers, see [Deploy your local framework changes](#deploy-your-local-framework-changes-contributors). | ## Configuration Copy the template and fill in your project endpoint: +PowerShell: + +```powershell +copy .env.example .env +``` + +Bash: + ```bash cp .env.example .env ``` -Edit `.env` and set your Foundry project endpoint: - ```env FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o ASPNETCORE_URLS=http://+:8088 -ASPNETCORE_ENVIRONMENT=Development -FOUNDRY_MODEL=gpt-4o +AZURE_TOKEN_CREDENTIALS=dev ``` -> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. +> `.env` is gitignored. The `.env.example` template is checked in as a reference. -## Running directly (contributors) +> `ASPNETCORE_URLS` pins the local run to the port the `Using-Samples` REPLs expect. Recent +> `Microsoft.Agents.AI.Foundry.Hosting` versions bind that port themselves, so it only matters +> while this project is pinned to an older published package. -This project uses `ProjectReference` to build against the local Agent Framework source. +> **Windows note:** write `.env` as UTF-8 **without** a byte order mark. `azd` reads the file +> during `azd ai agent init` and fails with `unexpected character "»" in variable name` when a mark +> is present. PowerShell's `Set-Content -Encoding UTF8BOM` adds one; use `-Encoding utf8NoBOM`. -```bash +> **Local development on a machine without a managed identity:** set `AZURE_TOKEN_CREDENTIALS=dev`. +> `Program.cs` authenticates with `DefaultAzureCredential` (the pattern the hosted platform +> expects, where a managed identity is injected). On a developer machine with no managed identity, +> `DefaultAzureCredential` probes the Azure Instance Metadata Service (IMDS, `169.254.169.254`) and +> blocks for a long time on the network timeout before every model call, so requests appear to +> hang. Setting `AZURE_TOKEN_CREDENTIALS=dev` restricts `DefaultAzureCredential` to developer +> credentials (Azure CLI, Visual Studio, `azd`) and skips the managed-identity probe. This variable +> is only for local runs; the deployed agent in Foundry uses the platform-injected managed identity. + +## Run and test locally + +Local runs use two terminals: one hosts the agent, the other is a code-first client that talks to it +using Agent Framework components, see the sibling [`Using-Samples`](../Using-Samples/) REPLs. + +`AddFoundryResponses` binds the app to the port Foundry probes for readiness (8088 by default, +overridable with the `PORT` environment variable), and `MapFoundryResponses` serves the standard +`POST /responses` route. That is the same route the platform routes to for a deployed agent, so the +local server needs no extra wiring: the client just points an OpenAI responses client at +`http://localhost:8088`. + +**Terminal 1 — host the agent:** + +``` cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent +az login dotnet run ``` -The agent will start on `http://localhost:8088`. +The agent starts on `http://localhost:8088`. -### Test it +**Terminal 2 — chat with it (code-first REPL):** -Using the Azure Developer CLI: +PowerShell: -```bash -azd ai agent invoke --local "Hello!" +```powershell +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent +$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent" +dotnet run -- --local ``` -Or with curl (specifying the agent name explicitly): +Bash: ```bash -curl -X POST http://localhost:8088/responses \ - -H "Content-Type: application/json" \ - -d '{"input": "Hello!", "model": "hosted-chat-client-agent"}' +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent +export AZURE_AI_AGENT_NAME="hosted-chat-client-agent" +dotnet run -- --local ``` -## Running with Docker +Without `--local` the REPL asks which agent to chat with; choose **2 (Local)**. Either way it +points an OpenAI responses client at the local server and streams the reply. -Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output. +## Deploy to Foundry (source / ZIP) -### 1. Publish for the container runtime (Linux Alpine) +`azd` scaffolds the project into a working folder, so every step below runs from an **empty +directory outside the repository**, and `-m` points at this sample's `azure.yaml`. -```bash -dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +### Step 1: create the working directory and enter it + +PowerShell: + +```powershell +$work = Join-Path $env:TEMP "hosted-chat-work" +mkdir $work +cd $work ``` -### 2. Build the Docker image +Bash: ```bash -docker build -f Dockerfile.contributor -t hosted-chat-client-agent . +WORK="${TMPDIR:-/tmp}/hosted-chat-work" +mkdir -p "$WORK" +cd "$WORK" ``` -### 3. Run the container +### Step 2: scaffold the project -Generate a bearer token on your host and pass it to the container: +`azd ai agent init` copies the sample into a subfolder named after the top-level `name:` in +`azure.yaml`, which is `hosted-chat-client-agent`. It also writes the adopted `azure.yaml` and the +`azd` environment there. -```bash -# Generate token (expires in ~1 hour) -export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +`azd ai agent init` prompts you to pick the Foundry project, so no project argument is needed. +`-d` is the name of an existing model deployment in that project; omit it and `azd` prompts for +that too. + +> Pick an **existing** project at the prompt. The prompt needs an interactive terminal: run +> non-interactively (in CI, for example) and `azd` skips it and provisions a brand new Foundry +> project and resource group instead. Pass `-p ` when you need that to be +> unattended. -# Run with token -docker run --rm -p 8088:8088 \ - -e AGENT_NAME=hosted-chat-client-agent \ - -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ - --env-file .env \ - hosted-chat-client-agent +`azure.yaml` passes the model deployment to the container by reading it from the `azd` environment. +Confirm it landed there, and set it yourself if it did not: + +``` +azd env get-values +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME ``` -> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration. +PowerShell: -### 4. Test it +```powershell +$sample = "/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/azure.yaml" -Using the Azure Developer CLI: +azd auth login +azd ai agent init -m $sample -d +``` + +Bash: ```bash -azd ai agent invoke --local "Hello!" +SAMPLE="/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/azure.yaml" + +azd auth login +azd ai agent init -m "$SAMPLE" -d ``` -Or with curl (specifying the agent name explicitly): +### Step 3: provision and deploy -```bash -curl -X POST http://localhost:8088/responses \ - -H "Content-Type: application/json" \ - -d '{"input": "Hello!", "model": "hosted-chat-client-agent"}' +Contributors: if you are changing the Agent Framework source in this repository and want the +deployed agent to run **your** build rather than the published packages, do the extra step in +[Deploy your local framework changes](#deploy-your-local-framework-changes-contributors) now, +before the commands below. Everyone else can ignore it. + +``` +cd hosted-chat-client-agent +azd provision +azd deploy +azd ai agent invoke "Hello!" ``` -## Deploying to Foundry (azd spec) +`azd` packages the source into a ZIP (honoring `.agentignore`), uploads it, and Foundry runs +`dotnet restore` + `dotnet publish` on it during provisioning (`dependencyResolution: remote_build` +in `azure.yaml`). No Dockerfile, no container registry. -This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. +You can also test the deployed agent with the REPL: -Initialize an `azd` project from this sample's manifest: +PowerShell: -```bash -mkdir hosted-chat-client-agent && cd hosted-chat-client-agent -azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml +```powershell +cd /dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent +$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent" +dotnet run -- --remote ``` -Then deploy: +Bash: ```bash -azd deploy +cd /dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export AZURE_AI_AGENT_NAME="hosted-chat-client-agent" +dotnet run -- --remote +``` + +### Step 4: clean up + +``` +azd down +``` + +Then delete the working directory. + +## Deploy your local framework changes (contributors) + +**Skip this section unless you are changing the Agent Framework itself.** Everything above is the +complete flow for using the sample. This section only applies when you are working on the framework +source in this repository, or when you otherwise need a build of it that is not published on +nuget.org. + +The reason it exists: the project restores the **published** Agent Framework packages, and Foundry +restores from nuget.org when it builds the upload. So editing framework source in this repository +changes nothing about the deployed agent, no matter how many times you rebuild locally. The +uploaded folder is self-contained and knows nothing about your working tree. + +The extra step packs your local framework source into NuGet packages and puts them **inside the +upload**, together with a `nuget.config` that points the restore at them. The server-side restore +then resolves the framework from the packages you shipped instead of from nuget.org. + +Run it in the flow above, **between step 2 and step 3**. Nothing else changes. + +Run it from `$work`, the working directory created in step 1, which now holds the +`hosted-chat-client-agent` folder that `azd ai agent init` scaffolded: + +PowerShell: + +```powershell +cd $work +/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 -Path ./hosted-chat-client-agent ``` -If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: +Bash: ```bash -azd env set AGENT_NAME hosted-chat-client-agent -azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +cd "$WORK" +/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh ./hosted-chat-client-agent ``` -For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). +Then continue with step 3. The path argument is optional: called without it, the script uses the +current directory, so you can also run it from inside `hosted-chat-client-agent`. + +The script changes three things in the scaffolded folder: + +| Change | Detail | +|--------|--------| +| Creates `local-feed/` | The Agent Framework packed from your local source, stamped with a version like `1.15.0-preview-local.` | +| Creates `nuget.config` | Resolves `Microsoft.Agents.AI*` from that folder and everything else from nuget.org | +| Edits the `.csproj` | Repoints its `AgentFrameworkVersion` property at the version just packed | + +Both generated files ship inside the ZIP, so the server-side restore resolves the framework from +the upload. The scaffolded folder is a throwaway copy, so the repository is left untouched. + +Two details worth knowing: + +- The version carries a timestamp because NuGet caches by package id and version. Reusing a version + would silently restore the previously packed bits instead of the build you just made. +- The whole package closure is packed, not just the two packages the sample references. Packing + only the leaf packages lets NuGet fill the rest from nuget.org, mixing a published core with a + locally built host, which fails to compile. + +Before spending a deploy, build the scaffolded folder locally. A restore problem surfaces in +seconds instead of after the server-side build: + +``` +cd hosted-chat-client-agent +dotnet build -c Debug --tl:off +``` + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` -## NuGet package users +Add `--new-session` as well if the failure persists. -If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative. +For the full hosted-agent deployment guide, see the [official source-code deployment doc](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent-code). diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml deleted file mode 100644 index bc699d3b4b0..00000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: hosted-chat-client-agent -displayName: "Hosted Chat Client Agent" - -description: > - A simple general-purpose AI assistant hosted as a Foundry Hosted Agent - using the Agent Framework instance hosting pattern. - -metadata: - tags: - - AI Agent Hosting - - Azure AI AgentServer - - Responses Protocol - - Streaming - - Agent Framework - -template: - name: hosted-chat-client-agent - kind: hosted - protocols: - - protocol: responses - version: 2.0.0 - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: [] -resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml deleted file mode 100644 index 0f97e93fed7..00000000000 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -kind: hosted -name: hosted-chat-client-agent -protocols: - - protocol: responses - version: 2.0.0 -resources: - cpu: "0.25" - memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/azure.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/azure.yaml new file mode 100644 index 00000000000..c12b5aaf94d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/azure.yaml @@ -0,0 +1,46 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: hosted-chat-client-agent +services: + ai-project: + host: azure.ai.project + hosted-chat-client-agent: + project: . + host: azure.ai.agent + language: csharp + uses: + - ai-project + codeConfiguration: + dependencyResolution: remote_build + entryPoint: HostedChatClientAgent.dll + runtime: dotnet_10 + # ASPNETCORE_URLS pins the listen port. Source deploy runs this project as a plain ASP.NET + # app, and the .NET base image defaults it to port 80, while Foundry probes port 8088 for + # readiness, so without it every invoke fails with HTTP 424 session_not_ready. Recent + # Microsoft.Agents.AI.Foundry.Hosting versions bind the port themselves and take precedence + # over this value, so it only matters when the project is pinned to an older package. + # + # ${AZURE_AI_MODEL_DEPLOYMENT_NAME} reads the model deployment `azd ai agent init` recorded + # in the active azd environment. Without it the container falls back to the default model + # name hardcoded in Program.cs, which may not exist in the target project. + env: + ASPNETCORE_URLS: http://+:8088 + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + container: + resources: + cpu: "0.5" + memory: 1Gi + description: | + A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the Agent Framework instance hosting pattern. + kind: hosted + metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming + - Agent Framework + name: hosted-chat-client-agent + protocols: + - protocol: responses + version: 2.0.0 diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md index 88a8cec48e9..63bad683488 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md @@ -145,3 +145,17 @@ If you are consuming the Agent Framework as a NuGet package (not building from s | **Model/instructions** | Set in `Program.cs` | Set in Foundry UI/CLI/API | | **Tools** | Defined in code | Configured in the platform | | **Use case** | Full control over agent behavior | Platform-managed agent with centralized config | + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/README.md index bc0dfdd581a..e58149f4de2 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/README.md @@ -157,3 +157,17 @@ If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedLocalCodeAct.csproj` for the `PackageReference` alternative. + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md index f1b132fbcfa..95718065f31 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md @@ -139,3 +139,17 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedLocalTools.csproj` for the `PackageReference` alternative. + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md index 337366a563f..a67d31c3881 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md @@ -115,3 +115,16 @@ Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the comme - [`Hosted-Toolbox/`](../Hosted-Toolbox/) — connects to a single Foundry Toolbox via the AF Foundry hosting bridge (`AddFoundryToolboxes` + `FoundryAITool.CreateHostedMcpToolbox`). - [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — same hosting bones as `Hosted-Toolbox/`, but the toolbox bundles three MCP tools each authenticated differently (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL. +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/README.md index dd954c493cf..987d768e5eb 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/README.md @@ -135,3 +135,17 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu ## NuGet package users If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedObservability.csproj` for the `PackageReference` alternative. + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md index 792b980db72..f38a7a4a943 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md @@ -142,3 +142,17 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedTextRag.csproj` for the `PackageReference` alternative. + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md index 48763f08990..ca869e687b8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md @@ -190,6 +190,7 @@ Send a test email to myself. # path #4 — | **HTTP 404 from a tool call** | Toolbox name mismatch (`TOOLBOX_NAME` vs the name in the portal), or the toolbox was deleted. | | **Server logs a warning "Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled"** | Local dev without the env var set. The agent will load with zero tools and respond as if it has none. Set `AZURE_AI_PROJECT_ENDPOINT` (local-dev fallback) or `FOUNDRY_PROJECT_ENDPOINT` to your project endpoint. | | **Tools appear but model never invokes them** | `instructions:` in `Program.cs` may not surface what each tool is for. Tighten the `allowed_tools` lists and rephrase prompts to mention the upstream service by name. | +| **`azd ai agent invoke` returns `404 not_found: Conversation '' not found`** | `azd` saves the session and conversation per agent and reuses them on the next invoke. Once the agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server. Pass `--new-conversation` (and `--new-session` if it persists) to start a fresh one. | ## Region and model compatibility diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md index c8f86575dea..a825bf576d6 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md @@ -109,3 +109,17 @@ Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the comme - [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — same hosting bones as this sample, but the toolbox bundles three MCP tools each authenticated differently (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL. - [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers. + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md index 76fe45866d8..4695dd937d1 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md @@ -129,3 +129,17 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative. + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. 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 index 0d722aef7e1..ba34b3e573b 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md @@ -152,3 +152,17 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu ## NuGet package users Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowHandoff.csproj` for the `PackageReference` alternative. + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. 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 8facce34395..643e59f67a2 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 @@ -135,3 +135,17 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu ## NuGet package users Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowSimple.csproj` for the `PackageReference` alternative. + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md index 656c2f247e0..29536a9d552 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md @@ -28,10 +28,17 @@ just a matter of changing `AZURE_AI_AGENT_NAME`. ## Local HTTP dev -When the target is a local `http://localhost:8088` dev server, the REPLs install a small -`HttpSchemeRewritePolicy`: `AIProjectClient`/`BearerTokenPolicy` require HTTPS, so the client -presents the endpoint as `https://` to satisfy the TLS check, then rewrites the scheme back to -`http://` right before the request hits the wire. This is local-development only. +`AIProjectClient` authenticates with a bearer token, and the client pipeline refuses to attach one +to a plain `http://` endpoint, failing with `InvalidOperationException: Bearer token authentication +is not permitted for non TLS protected (https) endpoints.` before the request is even sent. To +target a local dev server over HTTP, the REPLs install a small `HttpSchemeRewritePolicy`: the +client is pointed at an `https://` URI to satisfy that check, and the policy puts the scheme back +to `http://` right before the request hits the wire. This is local-development only. + +`SimpleAgent` applies it only on the Foundry path, and only when `FOUNDRY_PROJECT_ENDPOINT` is an +`http://` URL. Its `--local` path needs nothing of the sort: it points an `OpenAIClient` at the +server's standard `POST /responses` route with an api key, which carries no bearer token and so +never hits the TLS check. ## The clients diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs index b74e85e19d3..ee0c01cc689 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs @@ -1,43 +1,30 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ClientModel; using System.ClientModel.Primitives; using Azure.AI.Projects; using Azure.Identity; using DotNetEnv; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Foundry; +using OpenAI; +using OpenAI.Responses; // Load .env file if present (for local development) Env.TraversePath().Load(); -// FOUNDRY_PROJECT_ENDPOINT is the Foundry project endpoint. Shape: -// https:///api/projects/ -Uri projectEndpoint = new(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); +// Port the Hosted-* samples listen on when run locally with `dotnet run`. +const int LocalAgentPort = 8088; // AZURE_AI_AGENT_NAME is the registered server-side agent name. string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME") ?? throw new InvalidOperationException("AZURE_AI_AGENT_NAME is not set."); -// Derive the per-agent OpenAI endpoint that hosted Foundry agents require. -Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai"); +// Pick the server to talk to. `--local` and `--remote` mirror the flag `azd ai agent invoke` +// exposes; with neither, ask at startup. +bool useLocalAgent = ResolveTarget(args); -// ── Create an agent-framework agent backed by the remote agent endpoint ────── - -var options = new AIProjectClientOptions(); - -if (projectEndpoint.Scheme == "http") -{ - // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy - // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right - // before the request hits the wire. - projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri; - agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; - options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); -} - -var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options); -FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint); +AIAgent agent = useLocalAgent ? CreateLocalAgent() : CreateHostedAgent(agentName); +string target = useLocalAgent ? $"http://localhost:{LocalAgentPort}" : agentName; AgentSession session = await agent.CreateSessionAsync(); @@ -47,7 +34,7 @@ Console.WriteLine($""" ══════════════════════════════════════════════════════════ Simple Agent Sample - Connected to: {agentEndpoint} + Connected to: {target} Type a message or 'quit' to exit ══════════════════════════════════════════════════════════ """); @@ -90,10 +77,76 @@ Type a message or 'quit' to exit Console.WriteLine("Goodbye!"); +// Returns true when the client should target a locally running agent. `--local` and `--remote` +// answer the question up front, which is what non-interactive runs need; with neither, ask. +static bool ResolveTarget(string[] args) +{ + if (args.Contains("--local", StringComparer.OrdinalIgnoreCase)) { return true; } + if (args.Contains("--remote", StringComparer.OrdinalIgnoreCase)) { return false; } + + return PromptForLocalTarget(); +} + +// Asks whether to target a locally running agent or the one deployed to Foundry, and returns +// true for local. Defaults to remote on an empty answer, matching `azd ai agent invoke`, which +// targets Foundry unless --local is passed. +static bool PromptForLocalTarget() +{ + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("Which agent do you want to chat with?"); + Console.ResetColor(); + Console.WriteLine(" [1] Foundry (deployed agent) [default]"); + Console.WriteLine($" [2] Local (dotnet run, http://localhost:{LocalAgentPort})"); + Console.Write("Choice: "); + + string? choice = Console.ReadLine()?.Trim(); + Console.WriteLine(); + + return choice is "2"; +} + +// Builds an agent against a Hosted-* sample running locally. The sample serves the standard +// Responses route (POST /responses), so an OpenAI responses client pointed at the server reaches +// it directly. The server hosts its own agent and ignores both the model id and the api key, but +// the SDK requires them to shape the request. +static AIAgent CreateLocalAgent() +{ + var options = new OpenAIClientOptions { Endpoint = new Uri($"http://localhost:{LocalAgentPort}") }; + + return new OpenAIClient(new ApiKeyCredential("not-needed"), options) + .GetResponsesClient() + .AsAIAgent(model: "hosted-agent", name: "LocalHostedAgent"); +} + +// Builds an agent against an agent deployed to Foundry. Hosted agents are reached through their +// per-agent endpoint, which the platform routes to the container's /responses route. +static AIAgent CreateHostedAgent(string agentName) +{ + Uri projectEndpoint = new(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); + + Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai"); + + var options = new AIProjectClientOptions(); + + if (projectEndpoint.Scheme == Uri.UriSchemeHttp) + { + // For local HTTP dev: the client pipeline refuses to attach a bearer token to a plain + // HTTP endpoint, so point the client at an https:// URI to satisfy that check, then swap + // the scheme back to http:// right before the request hits the wire. + projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = Uri.UriSchemeHttps }.Uri; + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = Uri.UriSchemeHttps }.Uri; + options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); + } + + return new AIProjectClient(projectEndpoint, new AzureCliCredential(), options).AsAIAgent(agentEndpoint); +} + /// -/// For Local Development Only -/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient -/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. +/// For Local Development Only. +/// Rewrites HTTPS URIs to HTTP right before transport, allowing to +/// target a local HTTP dev server while satisfying the pipeline's TLS check: bearer tokens are +/// only attached to TLS-protected endpoints, so a plain http:// endpoint is rejected outright. /// internal sealed class HttpSchemeRewritePolicy : PipelinePolicy { @@ -114,7 +167,7 @@ private static void RewriteScheme(PipelineMessage message) var uri = message.Request.Uri!; if (uri.Scheme == Uri.UriSchemeHttps) { - message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; + message.Request.Uri = new UriBuilder(uri) { Scheme = Uri.UriSchemeHttp }.Uri; } } } diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/README.md index 6d98560bb27..c25d1221a4e 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/README.md @@ -1,8 +1,7 @@ # SimpleAgent A generic, agent-agnostic chat REPL for any hosted Foundry agent. Point it at a running -`Hosted-*` agent via `AZURE_AI_AGENT_NAME`, and it builds a `FoundryAgent` against that agent's -per-agent OpenAI endpoint and streams replies. This is the shared client that `Hosted-Toolbox`, +`Hosted-*` agent and it streams replies. This is the shared client that `Hosted-Toolbox`, `Hosted-Toolbox-AuthPaths`, and `Hosted-McpTools` reference for their end-to-end demos. It knows nothing about the agent's tools, toolboxes, files, or auth — those are entirely the @@ -18,29 +17,51 @@ See [`../README.md`](../README.md) for why these client REPLs exist at all. ## Configuration ```env -FOUNDRY_PROJECT_ENDPOINT=https:///api/projects/ AZURE_AI_AGENT_NAME= +FOUNDRY_PROJECT_ENDPOINT=https:///api/projects/ ``` -Both are required. `FOUNDRY_PROJECT_ENDPOINT` is the Foundry project endpoint URL and -`AZURE_AI_AGENT_NAME` is the registered server-side agent name. The sample builds the per-agent -OpenAI endpoint URL (`{FOUNDRY_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`) -from these. +`AZURE_AI_AGENT_NAME` is always required. `FOUNDRY_PROJECT_ENDPOINT` is the Foundry project +endpoint URL, required only when you target the deployed agent. ## Run -Against a local Hosted-Toolbox agent listening on `http://localhost:8088`: - ```powershell cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent -$env:FOUNDRY_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local" -$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-agent" +$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent" dotnet run ``` -When the project endpoint is `http://`, the client presents it as `https://` to satisfy the -bearer-token TLS check, then rewrites the scheme back to `http://` right before transport -(local-development only). +On startup the client asks which agent to chat with: + +```text +Which agent do you want to chat with? + [1] Foundry (deployed agent) [default] + [2] Local (dotnet run, http://localhost:8088) +Choice: +``` + +Pass `--local` or `--remote` to answer up front and skip the prompt, which is what scripted runs +need: + +``` +dotnet run -- --local +dotnet run -- --remote +``` + +This mirrors the `--local` flag on `azd ai agent invoke`. Use local while a `Hosted-*` sample is +running with `dotnet run`; use remote to reach the agent deployed to Foundry. + +The two choices differ only in how the agent is built: + +| Target | How the client reaches it | +|--------|---------------------------| +| Local | An `OpenAIClient` pointed at `http://localhost:8088`, then `GetResponsesClient().AsAIAgent(...)`. That hits the standard `POST /responses` route the local server already serves. The model id and api key are placeholders: the server runs its own agent and ignores both, but the SDK requires them to shape the request. | +| Foundry | An `AIProjectClient` plus the agent's per-agent endpoint (`{projectEndpoint}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`), which the platform routes to the container's `/responses` route. | + +The Foundry path also works against a local server that maps the per-agent route: set +`FOUNDRY_PROJECT_ENDPOINT` to an `http://` URL and the client installs a scheme-rewrite policy so +the bearer-token pipeline accepts it. See [Local HTTP dev](../README.md#local-http-dev). ## End-to-end demo @@ -49,7 +70,7 @@ With a hosted agent running: ```text ══════════════════════════════════════════════════════════ Simple Agent Sample -Connected to: https://localhost:8088/api/projects/local/agents/hosted-toolbox-agent/endpoint/protocols/openai +Connected to: http://localhost:8088 Type a message or 'quit' to exit ══════════════════════════════════════════════════════════ @@ -61,3 +82,17 @@ Goodbye! ``` The client only sent a chat prompt; the agent resolved its toolbox tools server-side and answered. + +## Troubleshooting + +**`azd ai agent invoke` fails with `404 not_found: Conversation '' not found`** + +`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the +agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server, +so every following invoke fails even though the agent itself is healthy. Start a fresh one: + +``` +azd ai agent invoke --new-conversation "Hello!" +``` + +Add `--new-session` as well if the failure persists. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj index 5e6d95dbf64..68f98dd6147 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj @@ -13,14 +13,15 @@ - + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 b/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 new file mode 100644 index 00000000000..4468960ab9d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 @@ -0,0 +1,157 @@ +#requires -Version 7 +<# +.SYNOPSIS + Rewires an already-scaffolded hosted-agent folder to build against the local Agent Framework + source, so `azd deploy` ships your framework changes instead of the published packages. +.DESCRIPTION + Source (ZIP) deploy uploads the agent folder and Foundry runs `dotnet restore` + `dotnet publish` + on it in the cloud. That restore pulls the Agent Framework from nuget.org, so a contributor's + local framework changes are never exercised. + + Run this after `azd ai agent init` and before `azd provision`. It changes two things in the + folder that `init` scaffolded: + + local-feed/ New. The Agent Framework packed from the local source tree, stamped with a + version derived from the repo's current VersionPrefix plus a `-preview-local` + suffix. The whole closure is packed: packing only the leaf packages lets NuGet + fill the rest from nuget.org, mixing a published core with a locally built host. + nuget.config New. Maps Microsoft.Agents.AI* to that folder feed and everything else to + nuget.org. + the .csproj Edited. Its AgentFrameworkVersion property is repointed at the version just + packed. + + Neither generated file is excluded by `.agentignore`, so they travel inside the ZIP and the + server-side restore uses them. + + Everything else stays identical to the end-user flow: you create the working directory, run + `azd ai agent init`, and finish with `azd provision`, `azd deploy`, and `azd ai agent invoke`. + The scaffolded folder is a throwaway copy, so editing its project file leaves the repository + untouched. +.PARAMETER Path + The folder `azd ai agent init` scaffolded, for example `./hosted-chat-client-agent`. + Defaults to the current directory. +.EXAMPLE + # From the working directory, after azd ai agent init created ./hosted-chat-client-agent + /dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 -Path ./hosted-chat-client-agent +.EXAMPLE + # From inside the scaffolded folder + cd hosted-chat-client-agent + /dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 +.NOTES + For contributors validating framework changes end to end. End users skip this script entirely and + get the published packages. +#> + +[CmdletBinding()] +param( + [string]$Path = '.' +) + +$ErrorActionPreference = 'Stop' + +# The Agent Framework closure the hosted samples resolve. Packing only the leaf packages makes +# NuGet satisfy the rest from nuget.org, producing assembly-reference errors at build time. +$frameworkProjects = @( + 'Microsoft.Agents.AI.Abstractions' + 'Microsoft.Agents.AI' + 'Microsoft.Agents.AI.Workflows' + 'Microsoft.Agents.AI.Foundry' + 'Microsoft.Agents.AI.Foundry.Hosting' +) + +$target = (Resolve-Path $Path).Path + +if (-not (Test-Path (Join-Path $target 'azure.yaml'))) { + throw "No azure.yaml in '$target'. Point -Path at the folder 'azd ai agent init' scaffolded." +} + +$projectFile = Get-ChildItem $target -Filter *.csproj -File | Select-Object -First 1 +if (-not $projectFile) { + throw "No .csproj in '$target'. This script targets .NET hosted agents." +} + +if (-not (Select-String -Path $projectFile.FullName -Pattern '' -Quiet)) { + throw "$($projectFile.Name) has no property to repoint at a local build." +} + +$hostedRoot = Split-Path -Parent $PSScriptRoot +$dotnetRoot = (Resolve-Path (Join-Path $hostedRoot '..' '..' '..')).Path +$srcRoot = Join-Path $dotnetRoot 'src' + +# Derive the package version from the repo so the packages track the current release line. +# The timestamp keeps every run unique: NuGet caches by id and version, so reusing a version would +# silently restore the previously packed bits instead of the build you just made. It also changes +# the ZIP contents on every run, which matters because Foundry mints a new agent version only when +# the uploaded ZIP changes. +$packagePropsPath = Join-Path $dotnetRoot 'nuget' 'nuget-package.props' +$versionMatch = Select-String -Path $packagePropsPath -Pattern '(.+?)' | Select-Object -First 1 +if (-not $versionMatch) { + throw "Could not read from $packagePropsPath." +} + +$versionPrefix = $versionMatch.Matches[0].Groups[1].Value +$version = "$versionPrefix-preview-local.$(Get-Date -Format 'yyyyMMddHHmmss')" + +$feedPath = Join-Path $target 'local-feed' +if (Test-Path $feedPath) { Remove-Item $feedPath -Recurse -Force } +New-Item -ItemType Directory -Path $feedPath -Force | Out-Null + +Write-Host "Wiring $(Split-Path -Leaf $target) to the local Agent Framework" -ForegroundColor Cyan +Write-Host " version: $version" +Write-Host '' + +foreach ($project in $frameworkProjects) { + $projectPath = Join-Path $srcRoot $project "$project.csproj" + Write-Host "Packing $project..." + + # Debug, not Release: the Release configuration runs the repo's formatting and analyzer passes, + # which rewrite source files and fail the build on style violations. Packing only needs runnable + # binaries, so Debug keeps the working tree untouched. + # + # PackageVersion (not Version) is the property the repo's packaging props use to stamp both the + # package version and its dependency ranges, so the packed packages reference each other at this + # version instead of the bare VersionPrefix. + dotnet build $projectPath -c Debug -p:PackageVersion=$version --tl:off | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Build failed for $project." } + + dotnet pack $projectPath -c Debug --no-build -o $feedPath -p:PackageVersion=$version --tl:off | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Pack failed for $project." } +} + +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +$nugetConfig = @' + + + + + + + + + + + + + + + + + +'@ +[System.IO.File]::WriteAllText((Join-Path $target 'nuget.config'), ($nugetConfig -replace "`r`n", "`n"), $utf8NoBom) + +# The scaffolded copy is disposable, so repointing its project file at the local build is safe and +# keeps the checked-in sample free of contributor-only scaffolding. Reruns are safe: the pattern +# matches whatever version is currently there. +$projectXml = [System.IO.File]::ReadAllText($projectFile.FullName) +$projectXml = $projectXml -replace '(?)[^<]*(?)', "`${open}$version`${close}" +[System.IO.File]::WriteAllText($projectFile.FullName, $projectXml, [System.Text.UTF8Encoding]::new($true)) + +Write-Host '' +Write-Host 'Done. Continue with the standard flow:' -ForegroundColor Green +Write-Host '' +Write-Host " cd `"$target`"" +Write-Host ' azd provision' +Write-Host ' azd deploy' +Write-Host ' azd ai agent invoke "Hello!"' diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh b/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh new file mode 100755 index 00000000000..e3556c8a695 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# +# Rewires an already-scaffolded hosted-agent folder to build against the local Agent Framework +# source, so `azd deploy` ships your framework changes instead of the published packages. +# +# Source (ZIP) deploy uploads the agent folder and Foundry runs `dotnet restore` + `dotnet publish` +# on it in the cloud. That restore pulls the Agent Framework from nuget.org, so a contributor's +# local framework changes are never exercised. +# +# Run this after `azd ai agent init` and before `azd provision`. It changes two things in the +# folder that `init` scaffolded: +# +# local-feed/ New. The Agent Framework packed from the local source tree, stamped with a +# version derived from the repo's current VersionPrefix plus a `-preview-local` +# suffix. The whole closure is packed: packing only the leaf packages lets NuGet +# fill the rest from nuget.org, mixing a published core with a locally built host. +# nuget.config New. Maps Microsoft.Agents.AI* to that folder feed and everything else to +# nuget.org. +# the .csproj Edited. Its AgentFrameworkVersion property is repointed at the version just +# packed. +# +# Neither generated file is excluded by `.agentignore`, so they travel inside the ZIP and the +# server-side restore uses them. +# +# Everything else stays identical to the end-user flow: you create the working directory, run +# `azd ai agent init`, and finish with `azd provision`, `azd deploy`, and `azd ai agent invoke`. +# The scaffolded folder is a throwaway copy, so editing its project file leaves the repository +# untouched. +# +# Usage: +# add-local-framework-feed.sh [path-to-scaffolded-folder] +# +# The path defaults to the current directory. +# +# This is the bash counterpart of Add-LocalFrameworkFeed.ps1. For contributors validating framework +# changes end to end. End users skip this script entirely and get the published packages. + +set -euo pipefail + +# The Agent Framework closure the hosted samples resolve. Packing only the leaf packages makes +# NuGet satisfy the rest from nuget.org, producing assembly-reference errors at build time. +framework_projects=( + Microsoft.Agents.AI.Abstractions + Microsoft.Agents.AI + Microsoft.Agents.AI.Workflows + Microsoft.Agents.AI.Foundry + Microsoft.Agents.AI.Foundry.Hosting +) + +target_input="${1:-.}" + +if [[ ! -d "$target_input" ]]; then + echo "Error: '$target_input' is not a directory." >&2 + exit 1 +fi + +target="$(cd "$target_input" && pwd)" + +if [[ ! -f "$target/azure.yaml" ]]; then + echo "Error: no azure.yaml in '$target'. Point the path at the folder 'azd ai agent init' scaffolded." >&2 + exit 1 +fi + +project_file="$(find "$target" -maxdepth 1 -name '*.csproj' | head -n 1)" + +if [[ -z "$project_file" ]]; then + echo "Error: no .csproj in '$target'. This script targets .NET hosted agents." >&2 + exit 1 +fi + +if ! grep -q '' "$project_file"; then + echo "Error: $(basename "$project_file") has no property to repoint at a local build." >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hosted_root="$(dirname "$script_dir")" +dotnet_root="$(cd "$hosted_root/../../.." && pwd)" +src_root="$dotnet_root/src" + +# Derive the package version from the repo so the packages track the current release line. +# The timestamp keeps every run unique: NuGet caches by id and version, so reusing a version would +# silently restore the previously packed bits instead of the build you just made. It also changes +# the ZIP contents on every run, which matters because Foundry mints a new agent version only when +# the uploaded ZIP changes. +package_props="$dotnet_root/nuget/nuget-package.props" +version_prefix="$(sed -n 's/.*\([^<]*\)<\/VersionPrefix>.*/\1/p' "$package_props" | head -n 1)" + +if [[ -z "$version_prefix" ]]; then + echo "Error: could not read VersionPrefix from $package_props." >&2 + exit 1 +fi + +version="$version_prefix-preview-local.$(date +%Y%m%d%H%M%S)" + +feed_path="$target/local-feed" +rm -rf "$feed_path" +mkdir -p "$feed_path" + +echo "Wiring $(basename "$target") to the local Agent Framework" +echo " version: $version" +echo + +for project in "${framework_projects[@]}"; do + project_path="$src_root/$project/$project.csproj" + echo "Packing $project..." + + # Debug, not Release: the Release configuration runs the repo's formatting and analyzer passes, + # which rewrite source files and fail the build on style violations. Packing only needs runnable + # binaries, so Debug keeps the working tree untouched. + # + # PackageVersion (not Version) is the property the repo's packaging props use to stamp both the + # package version and its dependency ranges, so the packed packages reference each other at this + # version instead of the bare VersionPrefix. + dotnet build "$project_path" -c Debug "-p:PackageVersion=$version" --tl:off >/dev/null + dotnet pack "$project_path" -c Debug --no-build -o "$feed_path" "-p:PackageVersion=$version" --tl:off >/dev/null +done + +cat > "$target/nuget.config" <<'EOF' + + + + + + + + + + + + + + + + + +EOF + +# The scaffolded copy is disposable, so repointing its project file at the local build is safe and +# keeps the checked-in sample free of contributor-only scaffolding. Reruns are safe: the pattern +# matches whatever version is currently there. sed rewrites the line in place, leaving the file's +# leading byte order mark untouched. +sed -i.bak "s|[^<]*|$version|" "$project_file" +rm -f "$project_file.bak" + +echo +echo "Done. Continue with the standard flow:" +echo +echo " cd \"$target\"" +echo " azd provision" +echo " azd deploy" +echo " azd ai agent invoke \"Hello!\"" diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index 8e2a6986f92..8bd86abee5e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -3,13 +3,16 @@ using System; using System.ClientModel.Primitives; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; using System.Runtime.CompilerServices; using Azure.AI.AgentServer.Responses; using Azure.Core; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -54,6 +57,7 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser ArgumentNullException.ThrowIfNull(services); services.AddResponsesServer(); services.AddHealthChecks(); + ConfigureFoundryListenPort(services); services.TryAddSingleton(_ => FileSystemAgentSessionStore.CreateDefault()); services.TryAddSingleton(); return services; @@ -90,6 +94,7 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser services.AddResponsesServer(); services.AddHealthChecks(); + ConfigureFoundryListenPort(services); agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault(); if (!string.IsNullOrWhiteSpace(agent.Name)) @@ -249,6 +254,104 @@ public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuild return endpoints; } + /// + /// Configuration key the Foundry hosting platform populates with a non-empty value inside a + /// hosted container. It is the documented way for container code to detect a Foundry context. + /// + internal const string FoundryHostingEnvironmentKey = "FOUNDRY_HOSTING_ENVIRONMENT"; + + /// + /// Configuration key holding the HTTP listen port, matching the Agent Server SDK. + /// + internal const string ListenPortKey = "PORT"; + + /// + /// Port the Foundry hosted runtime probes and routes to when is + /// not set, matching . + /// + internal const int DefaultListenPort = 8088; + + /// + /// Marker registered once per so the Foundry listen-port + /// configuration is applied at most once, even across multiple AddFoundryResponses calls. + /// + private sealed class FoundryListenPortMarker; + + /// + /// Binds Kestrel to the port the Foundry hosted runtime probes and routes to, so a plain + /// WebApplication.CreateBuilder host (Tier 3) works with no Dockerfile. Mirrors + /// AgentHostBuilder, which listens on the PORT value (default 8088). + /// + /// + /// + /// The listener is added only when configuration reports a Foundry container through + /// . A listener configured in code overrides the + /// addresses a host resolves from configuration, so adding it everywhere would silently move + /// any non-Foundry app off its configured address. + /// + /// + /// Both values come from rather than from + /// , which caches every value in a static constructor. Reading + /// through configuration keeps the decision observable when the host is built, honours the + /// host's configuration sources, and lets tests supply values without mutating the process + /// environment. + /// + /// + /// Inside a Foundry container the listener cannot be skipped based on ASPNETCORE_URLS: + /// the .NET base image always sets it to port 80, so such a guard would always trip and leave + /// the container failing the readiness probe with HTTP 424. It cannot key off the presence of + /// PORT either, because the platform sets that value only when it needs a port other + /// than the default. + /// + /// + /// Idempotent, and harmless when no Kestrel server is present (for example under + /// TestServer): the callback only runs when Kestrel + /// is resolved. + /// + /// + private static void ConfigureFoundryListenPort(IServiceCollection services) + { + if (services.Any(static d => d.ServiceType == typeof(FoundryListenPortMarker))) + { + return; + } + + services.AddSingleton(); + services.AddOptions() + .Configure(static (options, configuration) => + { + if (string.IsNullOrEmpty(configuration[FoundryHostingEnvironmentKey])) + { + return; + } + + options.ListenAnyIP(ResolveListenPort(configuration)); + }); + } + + /// + /// Reads the listen port from configuration, applying the same contract as + /// : when unset, otherwise + /// a port number in the range 1-65535. + /// + /// The configured value is not a valid port. + private static int ResolveListenPort(IConfiguration configuration) + { + var value = configuration[ListenPortKey]; + if (string.IsNullOrEmpty(value)) + { + return DefaultListenPort; + } + + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var port) || port is < 1 or > 65535) + { + throw new InvalidOperationException( + $"The {ListenPortKey} environment variable value '{value}' is not a valid port number (1-65535)."); + } + + return port; + } + /// /// Maps GET /readiness to the AspNetCore HealthChecks pipeline only when no /// route already serves that path. The duplicate guard scans diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryListenPortTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryListenPortTests.cs new file mode 100644 index 00000000000..5ea783b29eb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryListenPortTests.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Net; +using System.Reflection; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// Verifies that AddFoundryResponses adds a Kestrel listener on the Foundry hosted-runtime +/// port for a plain WebApplication.CreateBuilder (Tier 3) host, so a source (ZIP) deployed +/// agent passes the platform readiness probe with no Dockerfile pinning the port, and that it +/// leaves the addresses of a host running outside Foundry alone. +/// +/// +/// Every case supplies its values through an in-memory , so no test +/// mutates the process environment and the class stays safe to run in parallel. +/// +public sealed class FoundryListenPortTests +{ + private const string AspNetCoreUrlsKey = "ASPNETCORE_URLS"; + + [Fact] + public void AddFoundryResponses_WhenHosted_ListensOnFoundryPort() + { + // Arrange + var services = CreateServices(); + + // Act + services.AddFoundryResponses(); + + // Assert + Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(services)); + } + + [Fact] + public void AddFoundryResponses_WithAgentWhenHosted_ListensOnFoundryPort() + { + // Arrange + var services = CreateServices(); + var mockAgent = new Mock(); + mockAgent.SetupGet(a => a.Name).Returns("test-agent"); + + // Act + services.AddFoundryResponses(mockAgent.Object); + + // Assert + Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(services)); + } + + [Fact] + public void AddFoundryResponses_WhenNotHosted_LeavesAddressesAlone() + { + // Arrange: outside a Foundry container the host keeps whatever addresses it resolved from + // configuration, so registering the Responses protocol must not add a listener. + var services = CreateServices(hosted: false); + + // Act + services.AddFoundryResponses(); + + // Assert + Assert.Empty(GetCodeBackedPorts(services)); + } + + [Fact] + public void AddFoundryResponses_WhenHostedWithAspNetCoreUrlsSet_StillListensOnFoundryPort() + { + // Arrange: the .NET base image used by source (ZIP) deploy sets ASPNETCORE_URLS to port 80. + // Inside Foundry the listener must still be added, because a listener configured in code + // takes precedence over that setting. Skipping it here would leave the container on port 80 + // and fail every invocation with HTTP 424 session_not_ready. + var services = CreateServices(settings: new Dictionary + { + [AspNetCoreUrlsKey] = "http://+:80", + }); + + // Act + services.AddFoundryResponses(); + + // Assert + Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(services)); + } + + [Fact] + public void AddFoundryResponses_WhenHostedWithPortSet_ListensOnConfiguredPort() + { + // Arrange: the platform sets PORT only when it needs a port other than the default. + var services = CreateServices(settings: new Dictionary + { + [FoundryHostingExtensions.ListenPortKey] = "9099", + }); + + // Act + services.AddFoundryResponses(); + + // Assert + Assert.Equal([9099], GetCodeBackedPorts(services)); + } + + [Theory] + [InlineData("0")] + [InlineData("65536")] + [InlineData("not-a-port")] + public void AddFoundryResponses_WhenHostedWithInvalidPort_Throws(string port) + { + // Arrange + var services = CreateServices(settings: new Dictionary + { + [FoundryHostingExtensions.ListenPortKey] = port, + }); + services.AddFoundryResponses(); + + // Act & Assert + var exception = Assert.Throws(() => GetCodeBackedPorts(services)); + Assert.Contains(port, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void AddFoundryResponses_CalledTwiceWhenHosted_ListensOnFoundryPortOnce() + { + // Arrange + var services = CreateServices(); + + // Act + services.AddFoundryResponses(); + services.AddFoundryResponses(); + + // Assert: a duplicate ListenAnyIP on the same port fails Kestrel startup with + // "address already in use", so the listener must be added exactly once. + Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(services)); + } + + /// + /// Builds a service collection whose carries the supplied values, + /// marking the process as Foundry-hosted unless says otherwise. + /// + private static ServiceCollection CreateServices(bool hosted = true, Dictionary? settings = null) + { + settings ??= []; + if (hosted) + { + settings[FoundryHostingExtensions.FoundryHostingEnvironmentKey] = "foundry"; + } + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection(settings).Build()); + return services; + } + + /// + /// Builds the service provider, resolves the applied , and + /// returns the ports of every code-configured listener (those added via ListenAnyIP). + /// + private static List GetCodeBackedPorts(IServiceCollection services) + { + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + var property = typeof(KestrelServerOptions).GetProperty( + "CodeBackedListenOptions", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(property); + + var listenOptions = (IEnumerable)property!.GetValue(options)!; + var ports = new List(); + foreach (var listenOption in listenOptions) + { + if (listenOption.GetType().GetProperty("IPEndPoint")?.GetValue(listenOption) is IPEndPoint endpoint) + { + ports.Add(endpoint.Port); + } + } + + return ports; + } +}