diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 6e4f08086f..8d20f5196a 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -351,7 +351,6 @@ jobs: runs-on: ubuntu-latest environment: integration env: - targetFramework: net10.0 configuration: Release steps: - uses: actions/checkout@v6 @@ -368,31 +367,15 @@ jobs: with: global-json-file: ${{ github.workspace }}/dotnet/global.json - - name: Generate test solution (no samples) - shell: pwsh - run: | - ./dotnet/eng/scripts/New-FilteredSolution.ps1 ` - -Solution dotnet/agent-framework-dotnet.slnx ` - -TargetFramework $env:targetFramework ` - -Configuration $env:configuration ` - -ExcludeSamples ` - -OutputPath dotnet/filtered.slnx ` - -Verbose - - - name: Generate Foundry hosted IT filtered solution - shell: pwsh - run: | - ./dotnet/eng/scripts/New-FilteredSolution.ps1 ` - -Solution dotnet/filtered.slnx ` - -TargetFramework $env:targetFramework ` - -Configuration $env:configuration ` - -TestProjectNameFilter "Foundry.Hosting.IntegrationTests*" ` - -OutputPath dotnet/filtered-foundry-hosted.slnx ` - -Verbose - + # Build the test csproj directly instead of a filtered slnx + -f override. + # The test project pins TargetFrameworks=net10.0 and its ProjectReference closure + # gives MSBuild a single-rooted graph, so each multi-targeted dependency is invoked + # exactly once for net10.0. This avoids the MSB3026/MSB3491/MSB4018/MSB3883 file-lock + # collisions caused by parallel inner-builds racing on shared bin/obj output paths + # under the previous slnx + global TFM override approach. - name: Build Foundry hosted IT (and its deps) shell: bash - run: dotnet build dotnet/filtered-foundry-hosted.slnx -c "$configuration" -f "$targetFramework" --warnaserror + run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror - name: Azure CLI Login uses: azure/login@v2 @@ -405,13 +388,12 @@ jobs: # are picked up; the image tag is content-hashed across the test container source AND its # framework project references, so identical content is a no-op push. # - # `-UsePrebuiltProjectReferences` opts into the no-rebuild fast path: publish skips - # rebuilding ProjectReferences and consumes the DLLs the prior "Build Foundry hosted IT - # (and its deps)" step already produced. This avoids MSB3026 ("file is being used by - # another process") collisions caused by the previous build's shared-compilation server - # still holding file handles to those DLLs. Safe in CI because the prebuild step ran in - # the same job against the same source. Do not remove the prebuild step (the subsequent - # `dotnet test --no-build` step depends on it too). + # The script always passes --no-dependencies to dotnet publish so publish never re-touches + # the framework lib DLLs the prior "Build Foundry hosted IT (and its deps)" step produced. + # This structurally eliminates the MSB3026 collision that VBCSCompiler from the prebuild + # would otherwise cause by holding file handles to those DLLs. Do not remove the prebuild + # step: the subsequent `dotnet test --no-build` step and the publish's ProjectReference + # resolution both depend on the prebuilt outputs being present. - name: Build and push Foundry Hosted Agents test container id: build-foundry-hosted-image shell: pwsh @@ -421,14 +403,13 @@ jobs: if ([string]::IsNullOrWhiteSpace($registry)) { throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment." } - & "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry -UsePrebuiltProjectReferences | Tee-Object -FilePath $env:GITHUB_ENV -Append + & "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry | Tee-Object -FilePath $env:GITHUB_ENV -Append - name: Run Foundry Hosted Agents Integration Tests shell: pwsh working-directory: dotnet run: | - dotnet test --solution ./filtered-foundry-hosted.slnx ` - -f $env:targetFramework ` + dotnet test --project tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj ` -c $env:configuration ` --no-build -v Normal ` --report-xunit-trx ` diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 173eb4793b..38df2e4931 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -25,7 +25,7 @@ - + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 01c28beef8..d58a691599 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -313,6 +313,9 @@ + + + @@ -332,6 +335,7 @@ + diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf index 00e3e6571e..14d5262533 100644 --- a/dotnet/agent-framework-release.slnf +++ b/dotnet/agent-framework-release.slnf @@ -30,7 +30,8 @@ "src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj", "src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj", "src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj", - "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj" + "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj", + "src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj" ] } } diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs index 18f4eccd5f..ff60ac5aaf 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs @@ -8,6 +8,11 @@ // even if the process is interrupted mid-loop, but may also result in chat history that is not // yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases. // +// Additionally, this sample demonstrates the MessageInjectingChatClient feature, which allows tool +// code to inject new user messages during the function execution loop. When a tool or anything else enqueues +// a message via MessageInjectingChatClient.EnqueueMessages during the tool execution loop, the PerServiceCallChatHistoryPersistingChatClient +// detects the pending message before the next service call and includes the injected message in the request. +// // To use end-of-run persistence instead (atomic run semantics), remove the // RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run // persistence is the default behavior. @@ -54,6 +59,37 @@ static string GetTime([Description("The city name.")] string city) => _ => $"{city}: time data not available." }; +// This tool demonstrates message injection during the function execution loop. +// When called, it checks travel advisories for a city. If an advisory is active, it uses +// the ambient run context to resolve MessageInjectingChatClient and injects a follow-up user message +// asking for alternative destinations. The model will process this injected message on the next +// service call — even though the parent FunctionInvokingChatClient loop would otherwise stop. +[Description("Check current travel advisories for a city.")] +static string CheckTravelAdvisory([Description("The city name.")] string city) +{ + // Simulated travel advisory data. + var advisory = city.ToUpperInvariant() switch + { + "LONDON" => "Travel advisory: Severe fog warnings in London. Flights may be delayed or cancelled.", + "SEATTLE" => "Travel advisory: Heavy rainfall expected. Flooding possible in low-lying areas.", + _ => null + }; + + if (advisory is null) + { + return $"{city}: No active travel advisories."; + } + + // When an advisory is found, inject a follow-up question so the model automatically + // suggests alternatives without the user needing to ask. + var runContext = AIAgent.CurrentRunContext!; + runContext.Agent.GetService()?.EnqueueMessages( + runContext.Session!, + [new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]); + + return advisory; +} + // Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence. // The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat // history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory(). @@ -65,10 +101,11 @@ AIAgent agent = chatClient.AsAIAgent( { Name = "WeatherAssistant", RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, ChatOptions = new() { - Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.", - Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)] + Instructions = "You are a helpful travel assistant. When asked about cities, call the appropriate tools for each city.", + Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime), AIFunctionFactory.Create(CheckTravelAdvisory)] }, }); @@ -109,6 +146,18 @@ async Task RunNonStreamingAsync() response = await agent.RunAsync(FollowUp2, session); PrintAgentResponse(response.Text); PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId); + + // Fourth turn — demonstrates message injection during the function loop. + // The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up + // user message asking for alternative cities. After the tool completes, the internal loop + // in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message + // and calls the service again, so the model answers the follow-up automatically. + const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories."; + PrintUserMessage(TravelPrompt); + + response = await agent.RunAsync(TravelPrompt, session); + PrintAgentResponse(response.Text); + PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId); } async Task RunStreamingAsync() @@ -181,6 +230,30 @@ async Task RunStreamingAsync() Console.WriteLine(); PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId); + + // Fourth turn — demonstrates message injection during the function loop (streaming). + // The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up + // user message asking for alternative cities. After the tool completes, the internal loop + // in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message + // and calls the service again, so the model answers the follow-up automatically. + const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories."; + PrintUserMessage(TravelPrompt); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\n[Agent] "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(TravelPrompt, session)) + { + Console.Write(update); + + // During streaming we should be able to see updates to the chat history + // before the full run completes, as each service call is made and persisted. + PrintChatHistory(session, "During travel advisory run", ref lastChatHistorySize, ref lastConversationId); + } + + Console.WriteLine(); + PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId); } void PrintUserMessage(string message) diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj index 0db6ba9fe6..a2787b4130 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore new file mode 100644 index 0000000000..cf85b06faa --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore @@ -0,0 +1,6 @@ +**/bin +**/obj +**/.vs +**/.vscode +.env +*.user diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example new file mode 100644 index 0000000000..b8fe9e8e7a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example @@ -0,0 +1,5 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile new file mode 100644 index 0000000000..82f5e1b85c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedFiles.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor new file mode 100644 index 0000000000..7a34f9361d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-files . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-files -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-files +# +# 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", "HostedFiles.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj new file mode 100644 index 0000000000..fe36ca8ba2 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj @@ -0,0 +1,40 @@ + + + + net10.0 + enable + enable + false + HostedFiles + HostedFiles + $(NoWarn); + + + + + + + + + + + + PreserveNewest + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs new file mode 100644 index 0000000000..aba8f4ebef --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Hosted Files Agent - A hosted agent that exposes two distinct file knowledge sources +// through scoped, security-hardened tools: +// +// * Bundled files (image-baked) — files copied into the published output via the csproj +// rule. Live at /app/resources/ inside the container. +// Author-shipped knowledge that ships with every session. +// +// * Session files (per-session $HOME volume) — files uploaded at runtime via the alpha +// Azure.AI.Projects.AgentSessionFiles SDK. Live at $HOME inside the per-session +// container, which the platform sets to /home/session by default +// (container-image-spec.md line 127, "If you use the session files API, $HOME is +// also the base path for those operations"). +// +// Each source is exposed via a separate tool pair, each rooted at its own directory. +// Tools take a fileName, not a path: Path.GetFileName strips any directory components, +// then a canonicalize + StartsWith(root) check enforces the boundary. The model cannot +// be tricked into reading /etc/passwd or any path outside its tool's root, even via +// indirect prompt injection in an uploaded file. +// +// Required environment variables: +// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint +// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o) +// +// Optional: +// AGENT_NAME - Agent name (default: hosted-files) +// BUNDLED_FILES_DIR - Override the bundled-files root +// (default: /resources, i.e. /app/resources/) +// HOME - Standard env var; the per-session sandbox volume +// (default: /home/session in the platform-managed container) + +using System.ComponentModel; +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +// Bypass SampleEnvironment alias (which prompts on missing env vars) for optional values. +string? GetOptionalEnv(string key) => System.Environment.GetEnvironmentVariable(key); + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = GetOptionalEnv("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; + +// 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 in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// ── File roots (canonicalized once) ────────────────────────────────────────── + +// Bundled root: where csproj lands at runtime. +// In the container that resolves to /app/resources/. +string bundledRoot = Path.GetFullPath( + GetOptionalEnv("BUNDLED_FILES_DIR") + ?? Path.Combine(AppContext.BaseDirectory, "resources")); + +// Session root: the per-session $HOME volume mounted by the Foundry platform. +// Files uploaded via AgentSessionFiles.UploadSessionFileAsync(sessionStoragePath: "foo") +// land at $HOME/foo per container-image-spec.md line 172. +string sessionRoot = Path.GetFullPath( + GetOptionalEnv("HOME") + ?? "/home/session"); + +// ── Tools: bundled files (image-baked, /app/resources/) ────────────────────── + +[Description("List the names of files bundled with the agent (built-in knowledge that ships with the image).")] +string ListBundledFiles() => SafeListNames(bundledRoot); + +[Description("Read the full text contents of a bundled file by name. Bundled files are built-in knowledge shipped with the agent image.")] +string ReadBundledFile( + [Description("Name of the bundled file (no directory components). Must be one of the names returned by ListBundledFiles.")] string fileName) + => SafeRead(bundledRoot, fileName, scope: "bundled files"); + +// ── Tools: session files (per-session $HOME) ───────────────────────────────── + +[Description("List the names of files uploaded into the current session sandbox by the user (e.g., via AgentSessionFiles.UploadSessionFileAsync).")] +string ListSessionFiles() => SafeListNames(sessionRoot); + +[Description("Read the full text contents of a file uploaded into the current session by name. Session files are user-supplied data that lives only for the lifetime of this session.")] +string ReadSessionFile( + [Description("Name of the session file (no directory components). Must be one of the names returned by ListSessionFiles.")] string fileName) + => SafeRead(sessionRoot, fileName, scope: "session files"); + +// ── Path-safe helpers (defense-in-depth: GetFileName + canonicalize + StartsWith(root)) ── + +string SafeListNames(string root) +{ + try + { + if (!Directory.Exists(root)) + { + return string.Empty; + } + + return string.Join( + Environment.NewLine, + Directory.EnumerateFiles(root).Select(Path.GetFileName)); + } + catch (Exception ex) + { + return $"Error listing files: {ex.Message}"; + } +} + +string SafeRead(string root, string fileName, string scope) +{ + try + { + // Step 1: strip any directory components the model might have included. + string safeName = Path.GetFileName(fileName); + if (string.IsNullOrEmpty(safeName)) + { + return $"File '{fileName}' not found in {scope}."; + } + + // Step 2: combine with the root and canonicalize. + string fullPath = Path.GetFullPath(Path.Combine(root, safeName)); + + // Step 3: enforce the prefix boundary so a crafted name still cannot escape. + string rootPrefix = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + if (!fullPath.StartsWith(rootPrefix, StringComparison.Ordinal)) + { + return $"File '{fileName}' not found in {scope}."; + } + + return File.Exists(fullPath) + ? File.ReadAllText(fullPath) + : $"File '{fileName}' not found in {scope}."; + } + catch (Exception ex) + { + return $"Error reading '{fileName}': {ex.Message}"; + } +} + +// ── Create and host the agent ──────────────────────────────────────────────── + +AIAgent agent = new AIProjectClient(new Uri(endpoint), credential) + .AsAIAgent( + model: deploymentName, + instructions: """ + You are a friendly assistant that answers questions over two file sources: + + - Bundled files: built-in knowledge that ships with the agent image + (e.g., reference reports the author packaged with you). Tools: + ListBundledFiles, ReadBundledFile. + + - Session files: user-uploaded data for this session only (e.g., a CSV + the user wants you to analyse). Tools: ListSessionFiles, ReadSessionFile. + + Pick the tool pair by intent. If a name could match either source, list + both first. Always read the file before answering; do not guess. Quote + numbers and figures verbatim from the file. + """, + name: GetOptionalEnv("AGENT_NAME") ?? "hosted-files", + description: "Hosted agent that answers questions over bundled (image-baked) and session-uploaded files via two scoped tool pairs.", + tools: + [ + AIFunctionFactory.Create(ListBundledFiles), + AIFunctionFactory.Create(ReadBundledFile), + AIFunctionFactory.Create(ListSessionFiles), + AIFunctionFactory.Create(ReadSessionFile), + ]); + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable +/// once at startup. This should NOT be used in production. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = System.Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => this.GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(this.GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md new file mode 100644 index 0000000000..729aca5c5f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md @@ -0,0 +1,128 @@ +# Hosted-Files + +A hosted agent that demonstrates **two distinct file knowledge sources** through scoped, security-hardened tools: + +- **Bundled files** (image-baked) — files the author packages with the agent at build time. Live at `/app/resources/` inside the container, copied from this project's [`resources/`](./resources/) folder via the csproj `` rule. +- **Session files** (per-session `$HOME` volume) — files the user uploads at runtime via the alpha `Azure.AI.Projects.AgentSessionFiles` SDK. Live at `$HOME` inside the per-session container. The Foundry platform sets `HOME=/home/session` by default and roots the session-files API there per [`container-image-spec.md` line 172](https://github.com/microsoft/foundrysdk-specs/blob/main/specs/agents/hosted_agents/container-spec/docs/container-image-spec.md): *"If you use the session files API, `$HOME` is also the base path for those operations; any paths given in those API endpoints will be relative to `$HOME`."* + +## Tool surface + +Each source is exposed via its own tool pair, rooted at its own directory. The model picks by intent. + +| Tool | Source | Root | +|------|--------|------| +| `ListBundledFiles` | Bundled (image-baked) | `/app/resources/` | +| `ReadBundledFile` | Bundled (image-baked) | `/app/resources/` | +| `ListSessionFiles` | Session-uploaded | `$HOME` (`/home/session`) | +| `ReadSessionFile` | Session-uploaded | `$HOME` (`/home/session`) | + +## Security model — distinct tools, distinct sandboxes + +Each tool takes a `fileName` (no directory components allowed) and enforces three layers of defence inside the implementation: + +1. **`Path.GetFileName(input)`** strips any directory parts from the model-supplied name. `"../../etc/passwd"` becomes `"passwd"`. +2. **`Path.GetFullPath(Combine(root, name))`** canonicalises the path. +3. **`fullPath.StartsWith(root + DirectorySeparatorChar)`** rejects anything that resolves outside the tool's root. + +Failures return a controlled `"File '' not found in ."` rather than throwing or exposing the canonical path. + +This is why the agent has four narrowly-scoped tools instead of a single `ReadFile(path)`: + +- **Smaller per-tool attack surface.** Each tool has one purpose, one root, and no path-typed parameter. Even a buggy implementation can only leak its own directory. +- **Cross-boundary access is impossible by schema.** A prompt-injection attempt to make the bundled tool read a session path (or vice versa) does not even compile in the tool schema the model sees. +- **Read-only, non-recursive listing.** No write tools, no glob, no `..`. + +## Companion + +[`Using-Samples/SessionFilesClient`](../Using-Samples/SessionFilesClient/) — a thin chat REPL (same shape as [`SimpleAgent`](../Using-Samples/SimpleAgent/)) that points at the deployed Hosted-Files endpoint via `FoundryAgent` and lets you ask questions whose answers come from either file source. + +## Live proof of the session-files contract + +The end-to-end alpha-SDK round trip (client uploads via `AgentSessionFiles.UploadSessionFileAsync` → file arrives at `$HOME/` inside the per-session container → agent's `ReadSessionFile` tool reads it → response quotes the verbatim contents) is exercised live by [`SessionFilesHostedAgentTests.UploadedFile_IsReadByHostedAgentAsync`](../../../../../tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs) against the matching `session-files` scenario in the integration test container. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.example .env +``` + +Edit `.env`: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +``` + +> `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files +AGENT_NAME=hosted-files dotnet run +``` + +The agent starts on `http://localhost:8088`. + +## Try it from the SessionFilesClient REPL + +### Bundled files (works against any deployment, including local) + +```bash +cd ../Using-Samples/SessionFilesClient +$env:AGENT_ENDPOINT = "http://localhost:8088" +$env:AGENT_NAME = "hosted-files" +dotnet run + +You> What is the total revenue in the contoso file? +Agent> The contoso file reports total revenue of "$1,482.6M". +``` + +The agent calls `ListBundledFiles`, sees `contoso_q1_2026_report.txt`, calls `ReadBundledFile("contoso_q1_2026_report.txt")` (which resolves under `/app/resources/`), and quotes the figure verbatim. + +### Session files (against a deployed agent) + +Upload a file to a specific session via `azd ai agent files upload` or via the alpha `AgentSessionFiles` SDK (see the integration test for the SDK call), then ask the agent about it. The agent's `ReadSessionFile` tool reads from `$HOME` and surfaces the content the same way. + +## Running with Docker + +This project uses `ProjectReference`, so use `Dockerfile.contributor` which takes a pre-published output: + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +docker build -f Dockerfile.contributor -t hosted-files . + +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-files \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-files +``` + +The bundled `resources/` folder is part of the published output and ships inside the image. + +## NuGet package users + +If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor` and switch the `ProjectReference` entries in `HostedFiles.csproj` to `PackageReference` (commented section in the csproj). + +## Adding more bundled files + +Drop additional text files into [`resources/`](./resources/). The csproj `` rule picks them up on the next `dotnet build` / `docker build`. + +## Overrides + +| Env var | Purpose | Default | +|---------|---------|---------| +| `BUNDLED_FILES_DIR` | Override the bundled-files root the tools read from. | `/resources` (`/app/resources/` in container) | +| `HOME` | The per-session sandbox volume root the session-files tools read from. Set by the Foundry platform; can be overridden for local testing. | `/home/session` | \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml new file mode 100644 index 0000000000..cda1ba6494 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-files +displayName: "Hosted Files Agent" + +description: > + A hosted agent that answers questions over a small set of files bundled + with its container image (under /app/resources/). Two local C# function + tools (ListFiles, ReadFile) surface the bundled file contents to the model. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Bundled Files + - Local Tools + - Agent Framework + +template: + name: hosted-files + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml new file mode 100644 index 0000000000..f949ac09ee --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-files +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt new file mode 100644 index 0000000000..858192a7d3 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt @@ -0,0 +1,121 @@ +Contoso Corporation +Quarterly Report — Q1 2026 (Three months ended March 31, 2026) + +DISCLAIMER +This document contains fictional data for sample/demo purposes only. +Contoso is a fictional company; all figures below are fabricated. + +------------------------------------------------------------ +1. EXECUTIVE SUMMARY +------------------------------------------------------------ +Contoso delivered a solid first quarter, with total revenue of +$1,482.6M, up 11.4% year-over-year. Growth was led by the Cloud +Services segment (+22.7% YoY) and continued double-digit expansion +in International markets. Operating margin expanded 140 basis points +to 23.8% on disciplined cost management and improved gross margin. + +Key highlights: + - Revenue: $1,482.6M (YoY +11.4%) + - Gross profit: $912.0M (gross margin 61.5%) + - Operating income: $352.9M (operating margin 23.8%) + - Net income: $268.4M (net margin 18.1%) + - Diluted EPS: $1.27 (vs. $1.04 prior year) + - Free cash flow: $311.5M + - Cash & equivalents: $2,140.8M + +------------------------------------------------------------ +2. INCOME STATEMENT (USD millions, unaudited) +------------------------------------------------------------ + Q1 2026 Q1 2025 YoY % +Revenue 1,482.6 1,330.7 +11.4% +Cost of revenue 570.6 538.9 +5.9% +Gross profit 912.0 791.8 +15.2% + Gross margin 61.5% 59.5% +200 bps +Operating expenses + Research & development 241.4 220.5 +9.5% + Sales & marketing 218.7 205.1 +6.6% + General & administrative 99.0 88.6 +11.7% +Total operating expenses 559.1 514.2 +8.7% +Operating income 352.9 277.6 +27.1% + Operating margin 23.8% 20.9% +290 bps +Other income / (expense), net 8.4 5.1 +Income before taxes 361.3 282.7 +Provision for income taxes 92.9 72.6 +Net income 268.4 210.1 +27.7% +Diluted EPS (USD) 1.27 1.04 +22.1% + +------------------------------------------------------------ +3. REVENUE BY SEGMENT (USD millions) +------------------------------------------------------------ +Segment Q1 2026 Q1 2025 YoY % +Cloud Services 612.4 499.1 +22.7% +Productivity Software 448.9 422.6 +6.2% +Devices & Hardware 267.0 260.4 +2.5% +Professional Services 154.3 148.6 +3.8% +Total revenue 1,482.6 1,330.7 +11.4% + +------------------------------------------------------------ +4. REVENUE BY GEOGRAPHY (USD millions) +------------------------------------------------------------ +Region Q1 2026 Q1 2025 YoY % +North America 812.1 756.0 +7.4% +EMEA 388.5 340.2 +14.2% +Asia-Pacific 221.7 183.4 +20.9% +Latin America 60.3 51.1 +18.0% +Total revenue 1,482.6 1,330.7 +11.4% + +------------------------------------------------------------ +5. SELECTED BALANCE SHEET ITEMS (USD millions) +------------------------------------------------------------ + Mar 31, Dec 31, + 2026 2025 +Cash & equivalents 2,140.8 1,902.3 +Short-term investments 845.6 820.4 +Accounts receivable, net 1,012.7 988.5 +Total current assets 4,510.2 4,190.6 +Goodwill & intangibles 2,330.1 2,338.9 +Total assets 9,884.5 9,512.0 +Total current liabilities 2,118.4 2,054.7 +Long-term debt 1,750.0 1,750.0 +Total liabilities 4,402.6 4,310.5 +Total stockholders' equity 5,481.9 5,201.5 + +------------------------------------------------------------ +6. CASH FLOW HIGHLIGHTS (USD millions) +------------------------------------------------------------ + Q1 2026 Q1 2025 +Net cash from operating activities 382.0 298.7 +Capital expenditures (70.5) (62.1) +Free cash flow 311.5 236.6 +Share repurchases (120.0) (90.0) +Dividends paid (54.2) (48.6) + +------------------------------------------------------------ +7. KEY OPERATING METRICS +------------------------------------------------------------ +Cloud paid seats (millions) 48.6 39.7 +22.4% +Cloud net revenue retention 118% 114% +Active enterprise customers 18,420 16,905 +9.0% +Headcount (end of period) 22,140 20,610 +7.4% + +------------------------------------------------------------ +8. OUTLOOK — Q2 2026 GUIDANCE +------------------------------------------------------------ +Revenue: $1,520M – $1,560M (YoY +10% to +13%) +Operating margin: 23.5% – 24.5% +Diluted EPS: $1.30 – $1.36 +Capital expenditures: ~$80M + +Management remains confident in the full-year plan and reiterates +fiscal-year 2026 revenue growth of 10–12% and operating-margin +expansion of 100–150 basis points versus FY 2025. + +------------------------------------------------------------ +9. NOTES +------------------------------------------------------------ +- All figures are unaudited and rounded to one decimal place. +- Year-over-year comparisons are versus the same period in 2025. +- "Free cash flow" is defined as net cash from operating activities + less capital expenditures, and is a non-GAAP measure. +- This sample report is intended solely for demonstration of an + agent-driven document analysis pipeline. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj index 8871ea5242..366894856c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj index 359e8a35bb..270a3f7391 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj index 31dafe2280..0029f66e39 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj index ed24c32eea..a0138c710a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj index 458518c67b..e54d2f1b37 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj index 75d012413d..57c5852455 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs new file mode 100644 index 0000000000..e05e025c0d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + +// ── Create an agent-framework agent backed by the remote Hosted-Files agent ── + +var options = new AIProjectClientOptions(); + +if (agentEndpoint.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. + + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; + options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); +} + +var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); +FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); + +AgentSession session = await agent.CreateSessionAsync(); + +// ── REPL ────────────────────────────────────────────────────────────────────── + +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + Session Files Client + Connected to: {agentEndpoint} + Try: "Give me the total revenue in the contoso file." + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input, session)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); + +/// +/// 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. +/// +internal sealed class HttpSchemeRewritePolicy : PipelinePolicy +{ + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + 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; + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md new file mode 100644 index 0000000000..dbf9262ae8 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md @@ -0,0 +1,50 @@ +# SessionFilesClient + +A thin chat REPL that connects to a deployed [`Hosted-Files`](../../Hosted-Files/) agent via `FoundryAgent` and lets you ask questions whose answers come from the files bundled with that agent. Same shape as [`SimpleAgent`](../SimpleAgent/) — point it at an `AGENT_ENDPOINT`, build a `FoundryAgent`, run. + +The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled file contents to the model. The client knows nothing about files; that is entirely the agent's concern. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- A running [`Hosted-Files`](../../Hosted-Files/) agent (locally via `dotnet run` or deployed to Foundry) +- Azure CLI logged in (`az login`) + +## Configuration + +```env +AGENT_ENDPOINT=http://localhost:8088 +AGENT_NAME=hosted-files +``` + +`AGENT_ENDPOINT` defaults to `http://localhost:8088`. Override with the deployed agent endpoint when chatting against Foundry. + +## Run + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient +$env:AGENT_ENDPOINT = "http://localhost:8088" +$env:AGENT_NAME = "hosted-files" +dotnet run +``` + +## End-to-end demo + +With the [`Hosted-Files`](../../Hosted-Files/) agent running: + +```text +══════════════════════════════════════════════════════════ +Session Files Client +Connected to: http://localhost:8088/ +Try: "Give me the total revenue in the contoso file." +Type a message or 'quit' to exit +══════════════════════════════════════════════════════════ + +You> Give me the total revenue in the contoso file. +Agent> The contoso file reports total revenue of "$1,482.6M". + +You> quit +Goodbye! +``` + +The agent looked at its bundled files via `ListFiles`, picked `contoso_q1_2026_report.txt`, called `ReadFile`, and quoted the figure verbatim. The client only sent a chat prompt. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj new file mode 100644 index 0000000000..954036ba3b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + SessionFilesClient + session-files-client + $(NoWarn);NU1605;OPENAI001 + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj index 209edd9a82..71d9af8f71 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj @@ -1,4 +1,4 @@ - + $(TargetFrameworksCore) @@ -31,7 +31,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs index db66b30a88..6174e1b149 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs @@ -2,6 +2,7 @@ using System; using System.ClientModel; +using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; @@ -38,7 +39,28 @@ namespace Microsoft.Agents.AI.Foundry; [Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] public sealed class FoundryAgent : DelegatingAIAgent { - private readonly AIProjectClient _aiProjectClient; + /// + /// Default OAuth scope for the Azure AI resource. Matches the scope used by + /// Azure.AI.Extensions.OpenAI's internal authentication helper so the bearer token is + /// accepted by the Foundry control plane. + /// + private const string AzureAiResourceScope = "https://ai.azure.com/.default"; + + /// + /// The cached when one was supplied or constructed by the active + /// constructor. Null when the agent was constructed via the agent-endpoint constructor, which + /// does not build a full . + /// + private readonly AIProjectClient? _aiProjectClient; + + /// + /// Project-scoped . Always non-null. Used for project-level + /// operations such as . + /// In agent-endpoint mode this is built directly from the project root derived from the + /// supplied agent endpoint; in project-endpoint mode it is the cached client returned by + /// . + /// + private readonly ProjectOpenAIClient _projectOpenAIClient; /// /// Initializes a new instance of the class using the direct Responses API path. @@ -72,30 +94,49 @@ public sealed class FoundryAgent : DelegatingAIAgent out var aiProjectClient)) { this._aiProjectClient = aiProjectClient; + this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient(); } /// /// Initializes a new instance of the class from an agent-specific endpoint. /// - /// The agent-specific endpoint URI (must contain the agent name in the path). + /// + /// The agent-specific endpoint URI. Must be of the shape + /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai. + /// /// The authentication credential. - /// Optional configuration options for the . + /// + /// Optional configuration for the underlying . When supplied: + /// + /// The instance is passed through to the per-agent client; pipeline policies added via AddPolicy(...) on it execute on the per-agent traffic. + /// Endpoint and are owned by this constructor and are overwritten with values derived from ; any caller value is replaced. + /// For the project-level conversations client a separate fresh options bag is built that copies only , , , and UserAgentApplicationId; pipeline policies added via AddPolicy(...) do not propagate to the conversations pipeline. + /// + /// /// Optional tools to use when interacting with the agent. /// Provides a way to customize the creation of the underlying . /// Optional service provider for resolving dependencies required by AI functions. + /// or is null. + /// does not match the expected agent-endpoint shape. + /// + /// This is the lightweight constructor for invoking an existing Foundry hosted agent when the + /// caller already has the per-agent endpoint URL. It populates + /// and from the agent name parsed out of the endpoint + /// path; Description, Instructions, Temperature, and TopP are not + /// populated. Callers that need those fields hydrated from server-side state should use + /// AIProjectClient.AsAIAgent(ProjectsAgentVersion) or + /// AIProjectClient.AsAIAgent(ProjectsAgentRecord) instead. + /// public FoundryAgent( Uri agentEndpoint, AuthenticationTokenProvider credential, - AIProjectClientOptions? clientOptions = null, + ProjectOpenAIClientOptions? clientOptions = null, IList? tools = null, Func? clientFactory = null, IServiceProvider? services = null) - : base(CreateInnerAgentFromEndpoint( - CreateProjectClient(agentEndpoint, credential, clientOptions), - agentEndpoint, tools, clientFactory, services, - out var aiProjectClient)) + : base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services)) { - this._aiProjectClient = aiProjectClient; + this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions); } /// @@ -105,6 +146,7 @@ public sealed class FoundryAgent : DelegatingAIAgent : base(WireClientHeaders(Throw.IfNull(innerAgent))) { this._aiProjectClient = Throw.IfNull(aiProjectClient); + this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient(); } #region Convenience methods @@ -137,9 +179,7 @@ public sealed class FoundryAgent : DelegatingAIAgent /// A linked to the newly created server-side conversation. public async Task CreateConversationSessionAsync(CancellationToken cancellationToken = default) { - var conversationsClient = this._aiProjectClient - .GetProjectOpenAIClient() - .GetProjectConversationsClient(); + var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient(); var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value; @@ -161,6 +201,11 @@ public sealed class FoundryAgent : DelegatingAIAgent return this._aiProjectClient; } + if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient)) + { + return this._projectOpenAIClient; + } + return base.GetService(serviceType, serviceKey); } @@ -238,47 +283,181 @@ public sealed class FoundryAgent : DelegatingAIAgent OpenAIRequestPoliciesReflection.AddPolicyIfMissing( policies, ClientHeadersPolicy.Instance, - System.ClientModel.Primitives.PipelinePosition.PerCall); + PipelinePosition.PerCall); } return new ClientHeadersAgent(innerAgent); } - private static AIAgent CreateInnerAgentFromEndpoint( - AIProjectClient aiProjectClient, + /// + /// Builds the inner for the agent-endpoint constructor by + /// constructing a per-agent via the + /// ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions) + /// constructor with set. This routes the + /// outbound URL through the per-agent endpoint shape that the Foundry service expects for + /// hosted agents and lets the SDK auto-append the api-version query string. + /// Caller-supplied are passed through to the per-agent + /// client with Endpoint and + /// overridden by values derived from + /// ; any policies the caller added via AddPolicy + /// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last. + /// + private static AIAgent CreateInnerAgentFromAgentEndpoint( Uri agentEndpoint, + AuthenticationTokenProvider credential, + ProjectOpenAIClientOptions? clientOptions, IList? tools, Func? clientFactory, - IServiceProvider? services, - out AIProjectClient outClient) + IServiceProvider? services) { - outClient = aiProjectClient; + Throw.IfNull(agentEndpoint); + Throw.IfNull(credential); - AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/'); + var (agentName, _) = ParseAgentEndpoint(agentEndpoint); - ChatClientAgentOptions agentOptions = new() - { - Name = agentReference.Name, - ChatOptions = new() { Tools = tools }, - }; + var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions(); + perAgentOptions.Endpoint = agentEndpoint; + perAgentOptions.AgentName = agentName; + perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); + var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope); + var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions); + IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient(); if (clientFactory is not null) { chatClient = clientFactory(chatClient); } + ChatClientAgentOptions agentOptions = new() + { + Id = agentName, + Name = agentName, + ChatOptions = new() { Tools = tools }, + }; + return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services)); } + /// + /// Builds the project-scoped for the agent-endpoint + /// constructor by deriving the project root from the supplied agent endpoint and constructing + /// a fresh client without so the SDK + /// appends the standard /openai/v1 suffix expected for project-level surfaces such as + /// conversations. + /// + /// + /// Only the four observable primitive properties (, + /// , , + /// and UserAgentApplicationId) are copied from the caller's options bag. Pipeline + /// policies added via AddPolicy on the caller bag do not propagate because + /// does not publicly enumerate its policies. The MEAI + /// user-agent policy is appended last. + /// + private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint( + Uri agentEndpoint, + AuthenticationTokenProvider credential, + ProjectOpenAIClientOptions? clientOptions) + { + var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint); + + var projectOptions = new ProjectOpenAIClientOptions(); + if (clientOptions is not null) + { + if (clientOptions.RetryPolicy is not null) + { + projectOptions.RetryPolicy = clientOptions.RetryPolicy; + } + + if (clientOptions.NetworkTimeout is not null) + { + projectOptions.NetworkTimeout = clientOptions.NetworkTimeout; + } + + if (clientOptions.Transport is not null) + { + projectOptions.Transport = clientOptions.Transport; + } + + if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId)) + { + projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId; + } + } + + projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); + + return new ProjectOpenAIClient(projectRoot, credential, projectOptions); + } + + /// + /// Parses an agent endpoint URI of shape + /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai + /// and returns the agent name and the derived project-root URI. + /// + /// + /// Single source of truth for both agent-name extraction and project-root derivation. + /// Tolerates trailing slash, casing variants on /agents/ and the suffix segment, and + /// strips query string and fragment. Throws for inputs that + /// do not match the expected shape. + /// + /// + /// The endpoint is missing the /agents/ segment, has an empty agent name, or has a + /// suffix other than /endpoint/protocols/openai. + /// + internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint) + { + Throw.IfNull(agentEndpoint); + + const string AgentsSegment = "/agents/"; + const string ExpectedSuffix = "/endpoint/protocols/openai"; + + var path = agentEndpoint.AbsolutePath.TrimEnd('/'); + var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + throw new ArgumentException( + $"Expected an agent endpoint of shape 'https:///.../projects//agents//endpoint/protocols/openai' but got '{agentEndpoint}'. " + + "If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.", + nameof(agentEndpoint)); + } + + var afterAgents = path.Substring(idx + AgentsSegment.Length); + var nextSlash = afterAgents.IndexOf('/'); + if (nextSlash <= 0) + { + throw new ArgumentException( + $"Agent endpoint '{agentEndpoint}' is missing the '{ExpectedSuffix}' suffix.", + nameof(agentEndpoint)); + } + + var agentName = afterAgents.Substring(0, nextSlash); + var suffix = afterAgents.Substring(nextSlash); + if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.", + nameof(agentEndpoint)); + } + + var rootPath = path.Substring(0, idx); + var projectRoot = new UriBuilder(agentEndpoint) + { + Path = rootPath, + Query = string.Empty, + Fragment = string.Empty, + }.Uri; + + return (agentName, projectRoot); + } + private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null) { Throw.IfNull(endpoint); Throw.IfNull(credential); clientOptions ??= new AIProjectClientOptions(); - clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, System.ClientModel.Primitives.PipelinePosition.PerCall); + clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); return new AIProjectClient(endpoint, credential, clientOptions); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index 71e7df373f..06e22b8c18 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -1,7 +1,10 @@ - true + true $(NoWarn);OPENAI001 diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index e7340cec19..fad6b4e316 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -151,6 +151,36 @@ public sealed class ChatClientAgentOptions [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public bool RequirePerServiceCallChatHistoryPersistence { get; set; } + /// + /// Gets or sets a value indicating whether to include a + /// in the chat client pipeline. + /// + /// + /// + /// When set to , a is added to the pipeline + /// between the and the inner client. This enables external code + /// (such as tool delegates) to inject messages into the function execution loop via the + /// class, which can be resolved from the chat client using + /// GetService<MessageInjectingChatClient>(). + /// + /// + /// This setting can be used independently of , + /// however it is recommended to also enable per-service-call persistence when using message injection + /// so that injected messages are persisted to chat history between service calls. + /// + /// + /// When setting the setting to and + /// to , ensure that your custom chat client stack + /// includes a . You can add one manually via the + /// extension method. + /// + /// + /// + /// Default is . + /// + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public bool EnableMessageInjection { get; set; } + /// /// Creates a new instance of with the same values as this instance. /// @@ -168,5 +198,6 @@ public sealed class ChatClientAgentOptions WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict, ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict, RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence, + EnableMessageInjection = this.EnableMessageInjection, }; } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs index 5cf87aa950..22027a44a6 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs @@ -114,4 +114,38 @@ public static class ChatClientBuilderExtensions { return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient)); } + + /// + /// Adds a to the chat client pipeline. + /// + /// + /// + /// This decorator enables external code (such as tool delegates) to inject messages into the function + /// execution loop. It should be positioned between the and + /// the (or the leaf ) + /// in the pipeline. + /// + /// + /// The can be retrieved from the chat client via + /// GetService<MessageInjectingChatClient> to enqueue messages from tool delegates or other code. + /// + /// + /// This extension method is intended for use with custom chat client stacks when + /// is . + /// When is (the default), + /// the automatically includes this decorator in the pipeline when + /// is . + /// + /// + /// This decorator only works within the context of a running and will throw an + /// exception if used in any other stack. + /// + /// + /// The to add the decorator to. + /// The for chaining. + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public static ChatClientBuilder UseMessageInjection(this ChatClientBuilder builder) + { + return builder.Use(innerClient => new MessageInjectingChatClient(innerClient)); + } } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index b2c48ec572..5859e98032 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -63,13 +63,21 @@ public static class ChatClientExtensions }); } - // PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled. - // It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client. + // MessageInjectingChatClient is injected when EnableMessageInjection is enabled. + // It is registered after FunctionInvokingChatClient so that it sits between FIC and the inner client. // ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost. - // By adding our decorator second, the resulting pipeline is: - // FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient - // This allows the decorator to simulate service-stored chat history by loading history before - // each service call, persisting after each call, and returning a sentinel ConversationId. + // MessageInjectingChatClient enables injecting messages during the function loop and looping when needed. + if (options?.EnableMessageInjection is true) + { + chatBuilder.Use(innerClient => new MessageInjectingChatClient(innerClient)); + } + + // PerServiceCallChatHistoryPersistingChatClient is injected when RequirePerServiceCallChatHistoryPersistence is enabled. + // It is registered after MessageInjectingChatClient (if present) so it sits closest to the leaf client. + // The resulting pipeline is: + // FunctionInvokingChatClient → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → leaf IChatClient + // PerServiceCallChatHistoryPersistingChatClient simulates service-stored chat history by loading history + // before each service call, persisting after each call, and returning a sentinel ConversationId. if (options?.RequirePerServiceCallChatHistoryPersistence is true) { chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient)); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/MessageInjectingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/MessageInjectingChatClient.cs new file mode 100644 index 0000000000..5d43b55f3e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/MessageInjectingChatClient.cs @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A delegating chat client that supports injecting messages into the function execution loop. +/// +/// +/// +/// This decorator enables external code (such as tool delegates) to enqueue messages that will be +/// sent to the underlying model at the next opportunity. It sits between the +/// and the (or the leaf ) +/// in a pipeline. +/// +/// +/// The injected messages queue is stored per-session in the , ensuring +/// isolation between concurrent sessions. +/// +/// +/// After each service call, if no actionable is returned but injected +/// messages are pending, the decorator loops internally and calls the inner client again with the new +/// messages. When actionable function calls are present, control returns to the parent +/// loop. +/// +/// +/// This chat client must be used within the context of a running . It retrieves the +/// current session from , which is set automatically when an agent's +/// or +/// +/// method is called. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class MessageInjectingChatClient : DelegatingChatClient +{ + /// + /// The key used to store the pending injected messages queue in the session's . + /// + internal const string PendingMessagesStateKey = "MessageInjectingChatClient.PendingInjectedMessages"; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying chat client that will handle the core operations. + public MessageInjectingChatClient(IChatClient innerClient) + : base(innerClient) + { + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var session = GetRequiredSession(); + var queue = GetOrCreateQueue(session); + + var newMessages = DrainInjectedMessages(queue, messages as IList ?? messages.ToList()); + + // Loop to process injected messages: after each service call, if no actionable function calls + // are pending but new messages have been injected into the queue, we call the service again + // so the model can process them. The loop exits when the response contains actionable + // function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty. + while (true) + { + var response = await base.GetResponseAsync(newMessages, options, cancellationToken).ConfigureAwait(false); + + // If the response contains actionable function calls, the parent FunctionInvokingChatClient + // loop will iterate — return immediately so it can process them. + if (HasActionableFunctionCalls(response.Messages)) + { + return response; + } + + // No actionable function calls. If there are pending injected messages, loop again + // to send them to the service. Otherwise, we're done. + bool queueEmpty; + lock (queue) + { + queueEmpty = queue.Count == 0; + } + + if (queueEmpty) + { + return response; + } + + // Propagate any ConversationId returned by the service so subsequent iterations + // continue within the same conversation. + UpdateOptionsForNextIteration(ref options, response.ConversationId); + + newMessages = DrainInjectedMessages(queue, Array.Empty()); + } + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var session = GetRequiredSession(); + var queue = GetOrCreateQueue(session); + + var newMessages = DrainInjectedMessages(queue, messages as IList ?? messages.ToList()); + + // Loop to process injected messages: after each service call, if no actionable function calls + // are pending but new messages have been injected into the queue, we call the service again + // so the model can process them. The loop exits when the response contains actionable + // function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty. + while (true) + { + bool hasActionableFunctionCalls = false; + string? lastConversationId = null; + + var enumerator = base.GetStreamingResponseAsync(newMessages, options, cancellationToken).GetAsyncEnumerator(cancellationToken); + try + { + while (await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + var update = enumerator.Current; + + // Check each update for actionable function call content as it streams through. + if (!hasActionableFunctionCalls && HasActionableFunctionCalls(update)) + { + hasActionableFunctionCalls = true; + } + + // Track the latest ConversationId from the stream. + if (update.ConversationId is not null) + { + lastConversationId = update.ConversationId; + } + + yield return update; + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + + // If the response contains actionable function calls, the parent FunctionInvokingChatClient + // loop will iterate — return immediately so it can process them. + if (hasActionableFunctionCalls) + { + yield break; + } + + // No actionable function calls. If there are pending injected messages, loop again + // to send them to the service. Otherwise, we're done. + bool queueEmpty; + lock (queue) + { + queueEmpty = queue.Count == 0; + } + + if (queueEmpty) + { + yield break; + } + + // Propagate any ConversationId returned by the service so subsequent iterations + // continue within the same conversation. + UpdateOptionsForNextIteration(ref options, lastConversationId); + + newMessages = DrainInjectedMessages(queue, Array.Empty()); + } + } + + /// + /// Enqueues one or more messages to be used at the next opportunity. + /// + /// + /// This method is thread-safe and can be called concurrently from tool delegates or other code + /// while the function execution loop is in progress. The enqueued messages will be picked up + /// at the next opportunity. + /// + /// The agent session to enqueue messages for. + /// The messages to enqueue. + public void EnqueueMessages(AgentSession session, IEnumerable messages) + { + Throw.IfNull(session); + Throw.IfNull(messages); + + var queue = GetOrCreateQueue(session); + + lock (queue) + { + foreach (var message in messages) + { + queue.Add(message); + } + } + } + + /// + /// Gets or creates the pending injected messages queue from the session's . + /// + private static List GetOrCreateQueue(AgentSession session) + { + if (session.StateBag.TryGetValue>(PendingMessagesStateKey, out var queue)) + { + return queue!; + } + + var newQueue = new List(); + session.StateBag.SetValue(PendingMessagesStateKey, newQueue); + return newQueue; + } + + /// + /// Gets the current from the run context. + /// + private static AgentSession GetRequiredSession() + { + var runContext = AIAgent.CurrentRunContext + ?? throw new InvalidOperationException( + $"{nameof(MessageInjectingChatClient)} can only be used within the context of a running AIAgent. " + + "Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call."); + + return runContext.Session + ?? throw new InvalidOperationException( + $"{nameof(MessageInjectingChatClient)} requires a session. " + + "The current run context does not have a session."); + } + + /// + /// Drains all pending injected messages from the queue and returns a new list combining + /// the original messages with the drained messages. The original list is never modified. + /// + private static IList DrainInjectedMessages(List queue, IList newMessages) + { + lock (queue) + { + if (queue.Count == 0) + { + return newMessages; + } + + var combined = new List(newMessages); + combined.AddRange(queue); + queue.Clear(); + return combined; + } + } + + /// + /// Determines whether any message in the list contains a + /// that is not marked as . + /// + private static bool HasActionableFunctionCalls(IList responseMessages) + { + for (int i = 0; i < responseMessages.Count; i++) + { + var contents = responseMessages[i].Contents; + for (int j = 0; j < contents.Count; j++) + { + if (contents[j] is FunctionCallContent fcc && !fcc.InformationalOnly) + { + return true; + } + } + } + + return false; + } + + /// + /// Determines whether a streaming update contains a + /// that is not marked as . + /// + private static bool HasActionableFunctionCalls(ChatResponseUpdate update) + { + var contents = update.Contents; + for (int i = 0; i < contents.Count; i++) + { + if (contents[i] is FunctionCallContent fcc && !fcc.InformationalOnly) + { + return true; + } + } + + return false; + } + + /// + /// Propagates the from the service response into + /// so that subsequent loop iterations continue within the + /// same conversation. Clones before mutating to avoid + /// affecting the caller's instance. + /// + private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId) + { + if (options is null) + { + if (conversationId is not null) + { + options = new() { ConversationId = conversationId }; + } + } + else if (options.ConversationId != conversationId) + { + options = options.Clone(); + options.ConversationId = conversationId; + } + } +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs index 3ec4665672..052786890b 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs @@ -32,6 +32,7 @@ AIAgent agent = scenario switch "toolbox" => CreateToolboxAgent(projectClient, deployment), "mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment), "custom-storage" => CreateCustomStorageAgent(projectClient, deployment), + "session-files" => CreateSessionFilesAgent(projectClient, deployment), _ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.") }; @@ -106,6 +107,30 @@ static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deploymen name: "custom-storage-agent", description: "Custom storage test agent (placeholder)."); +// session-files scenario: agent reads files from $HOME inside the per-session sandbox volume. +// Mirrors the dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files sample. +static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment) => + client.AsAIAgent( + model: deployment, + instructions: """ + You are a friendly assistant that helps users inspect and summarise + files stored in the session sandbox at $HOME. + + Always answer file-related questions by calling the available tools + (GetHomeDirectory, ListFiles, ReadFile). Do not guess file paths or + contents — read the file before answering. + + Quote numbers and figures verbatim from the file rather than + paraphrasing them. + """, + name: "session-files-agent", + description: "Reads files from the per-session $HOME volume.", + tools: [ + AIFunctionFactory.Create(GetHomeDirectory), + AIFunctionFactory.Create(ListFiles), + AIFunctionFactory.Create(ReadFile) + ]); + [Description("Returns the current UTC date and time as an ISO 8601 string.")] static string GetUtcNow() => DateTime.UtcNow.ToString("o"); @@ -120,3 +145,74 @@ static string SendEmail( [Description("Returns the deployment environment name.")] static string GetEnvironmentName() => "integration-test"; + +// session-files tools: resolve paths against $HOME (the per-session sandbox volume). +[Description("Get the absolute path of the session home directory ($HOME).")] +static string GetHomeDirectory() => SessionHome(); + +[Description("List files and directories under the given path inside the session sandbox. Pass an empty string to list $HOME.")] +static string[] ListFiles( + [Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path) +{ + try + { + return Directory.EnumerateFileSystemEntries(ResolveSessionPath(path)).ToArray(); + } + catch (Exception ex) + { + return [$"Error listing '{path}': {ex.Message}"]; + } +} + +[Description("Read the full text contents of a file inside the session sandbox.")] +static string ReadFile( + [Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path) +{ + try + { + return File.ReadAllText(ResolveSessionPath(path)); + } + catch (Exception ex) + { + return $"Error reading '{path}': {ex.Message}"; + } +} + +static string SessionHome() => + Environment.GetEnvironmentVariable("HOME") + ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + +// Resolve a caller-supplied path against $HOME, rejecting absolute paths and traversal segments +// so that the model cannot read or list arbitrary container files via the ReadFile/ListFiles +// tools (defense-in-depth against indirect prompt injection). Mirrors the canonicalize + +// startsWith($HOME) pattern used by FileSystemAgentFileStore.ResolveSafePath. +static string ResolveSessionPath(string path) +{ + string home = SessionHome(); + string homeFull = Path.GetFullPath(home); + string homePrefix = homeFull.EndsWith(Path.DirectorySeparatorChar) + ? homeFull + : homeFull + Path.DirectorySeparatorChar; + + if (string.IsNullOrWhiteSpace(path)) + { + return homeFull; + } + + if (Path.IsPathRooted(path)) + { + throw new ArgumentException($"Absolute paths are not allowed: '{path}'.", nameof(path)); + } + + string combined = Path.Combine(homeFull, path); + string fullPath = Path.GetFullPath(combined); + + if (!fullPath.Equals(homeFull, StringComparison.Ordinal) && + !fullPath.StartsWith(homePrefix, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Path '{path}' resolves outside the session sandbox.", nameof(path)); + } + + return fullPath; +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs new file mode 100644 index 0000000000..1470a23fba --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Foundry.Hosting.IntegrationTests.Fixtures; + +/// +/// Provisions a hosted agent that runs the test container in IT_SCENARIO=session-files mode. +/// The container exposes three local function tools (GetHomeDirectory, ListFiles, +/// ReadFile) that read from the per-session $HOME sandbox volume — mirroring the +/// Hosted-Files sample. Tests use the alpha +/// API to upload a file into the session +/// sandbox, then invoke the agent (pinned to the same agent_session_id) and assert that the +/// agent's tools observed the uploaded file. +/// +public sealed class SessionFilesHostedAgentFixture : HostedAgentFixture +{ + protected override string ScenarioName => "session-files"; +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj b/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj index 18710dc791..a8ad919198 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj @@ -20,8 +20,16 @@ + + + + + + diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md index 764a366289..91f2f6983d 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md @@ -138,6 +138,7 @@ human-only operation; CI only adds and deletes versions under existing agents. | `ToolboxHostedAgentFixture` | `toolbox` | `it-toolbox` | Server registered toolbox tool callable; client side additions visible (placeholder). | | `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). | | `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). | +| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. | The placeholder scenarios will be wired up in the test container `Program.cs` once the relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize. diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs new file mode 100644 index 0000000000..7c1407ac65 --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable AAIP001 // AgentSessionFiles is experimental +#pragma warning disable OPENAI001 // CreateResponseOptions is experimental + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Foundry.Hosting.IntegrationTests.Fixtures; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; +using Shared.IntegrationTests; + +namespace Foundry.Hosting.IntegrationTests; + +/// +/// End-to-end integration test for the Hosted-Files style scenario: a file uploaded by the client +/// via the alpha SDK is read by the deployed hosted agent's +/// container-side ReadFile tool and surfaces in . +/// +/// +/// +/// Routing both invocations to the same per-session container requires two clients on the same +/// agent-scoped : a to +/// pre-create a conversation bound to the agent endpoint, and a +/// for invocation. The session id resolved by the platform on the first call is captured from the +/// x-agent-session-id response header and used to target the +/// upload at the same session's $HOME. The second call +/// carries the same conversation_id so it lands in the same container and the agent's +/// ReadFile tool sees the upload. +/// +/// +[Trait("Category", "FoundryHostedAgents")] +public sealed class SessionFilesHostedAgentTests(SessionFilesHostedAgentFixture fixture) : IClassFixture +{ + private const string FoundryFeaturesHeader = "Foundry-Features"; + private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview,AgentEndpoints=V1Preview"; + private const string SessionIdHeader = "x-agent-session-id"; + + private const string TestDataFileName = "contoso_q1_2026_report.txt"; + + /// Token that appears verbatim in the test data file. Proof the agent read what we uploaded. + private const string ExpectedTokenInFile = "1,482.6"; + + private readonly SessionFilesHostedAgentFixture _fixture = fixture; + + [Fact] + public async Task UploadedFile_IsReadByHostedAgentAsync() + { + // Arrange + string localPath = Path.Combine(AppContext.BaseDirectory, "TestData", TestDataFileName); + Assert.True( + File.Exists(localPath), + $"Test data file not found at '{localPath}'. Confirm the linked Content entry in the csproj."); + + var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)); + var credential = TestAzureCliCredentials.CreateAzureCliCredential(); + + // Admin client + AgentSessionFiles for upload/list/delete (alpha SDK). + var adminOptions = new AgentAdministrationClientOptions(); + adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall); + var adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions); + var sessionFiles = adminClient.GetAgentSessionFiles(); + + // Build the per-agent OpenAI client. The conversation is created on this client so it is + // bound to the agent endpoint URL (`/agents/{name}/endpoint/protocols/openai/conversations`). + // A header-capture policy reads the `x-agent-session-id` the platform stamps on every reply. + var headerCapture = new ResponseHeaderCapturePolicy(SessionIdHeader); + var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this._fixture.AgentName }; + openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall); + openAIOptions.AddPolicy(headerCapture, PipelinePosition.PerCall); + var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions); + var conversations = openAIClient.GetProjectConversationsClient(); + var responses = openAIClient.GetProjectResponsesClient(); + + // Step 1 — create a conversation bound to the agent endpoint. Subsequent /responses calls + // tagged with this conversation_id route to the same per-session container. + var conversation = await conversations.CreateProjectConversationAsync(); + string conversationId = conversation.Value.Id; + + try + { + // Step 2 — warm-up call. Provisions the per-session container under the conversation and + // lets us read back the resolved agent_session_id from the response header. + var agent = responses.AsIChatClient().AsAIAgent(name: this._fixture.AgentName); + var convOptions = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId }); + + var warmup = await agent.RunAsync( + "Reply with the single word 'ready' and nothing else.", + options: convOptions); + Assert.False(string.IsNullOrWhiteSpace(warmup.Text)); + + string agentSessionId = headerCapture.LastValue + ?? throw new InvalidOperationException( + $"Expected '{SessionIdHeader}' response header on warm-up but got none."); + + try + { + // Step 3 — upload the file via the alpha AgentSessionFiles SDK to that exact session's $HOME. + SessionFileWriteResponse writeResponse = await sessionFiles.UploadSessionFileAsync( + agentName: this._fixture.AgentName, + sessionId: agentSessionId, + sessionStoragePath: TestDataFileName, + localPath: localPath); + + long expectedBytes = new FileInfo(localPath).Length; + Assert.Equal(expectedBytes, writeResponse.BytesWritten); + + SessionDirectoryListResponse listing = await sessionFiles.GetSessionFilesAsync( + agentName: this._fixture.AgentName, + sessionId: agentSessionId, + sessionStoragePath: "."); + Assert.Contains( + listing.Entries, + e => e.Name == TestDataFileName && !e.IsDirectory && e.Size == expectedBytes); + + // Step 4 — invoke the agent again on the SAME conversation. The platform routes back to + // the same agent_session_id container, so the agent's ReadFile tool sees the upload. + // The platform mutates session/conversation revision when AgentSessionFiles uploads land, + // so an immediate /responses follow-up races and 400's with "modified concurrently. Please + // retry." — the response message literally tells us to retry. Bounded retry handles it. + var readOptions = new CreateResponseOptions { AgentConversationId = conversationId }; + readOptions.InputItems.Add(ResponseItem.CreateUserMessageItem( + $"Read {TestDataFileName} from $HOME and quote the headline total revenue figure verbatim, no commentary.")); + + ClientResult rawResponse = null!; + const int MaxAttempts = 5; + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + rawResponse = await responses.CreateResponseAsync(readOptions); + break; + } + catch (ClientResultException ex) when ( + ex.Status == 400 && + ex.Message.Contains("modified concurrently", StringComparison.OrdinalIgnoreCase) && + attempt < MaxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(2 * attempt)); + } + } + + string responseText = rawResponse.Value.GetOutputText() ?? string.Empty; + + Assert.Equal(agentSessionId, headerCapture.LastValue); + + // Assert: the response contains the deterministic token from the file. + Assert.False(string.IsNullOrWhiteSpace(responseText)); + Assert.Contains(ExpectedTokenInFile, responseText); + } + finally + { + // Best-effort cleanup of the uploaded file. The session itself is left for TTL expiry — + // the platform owns its lifecycle (no isolation key in our hands). + try + { + await sessionFiles.DeleteSessionFileAsync( + agentName: this._fixture.AgentName, + sessionId: agentSessionId, + path: TestDataFileName); + } + catch + { + // Ignore. + } + } + } + finally + { + await this._fixture.DeleteConversationAsync(conversationId); + } + } + + /// + /// Captures a response header value on every pipeline call. Latest value is read after the + /// response completes. Used to grab the platform's x-agent-session-id stamp. + /// + private sealed class ResponseHeaderCapturePolicy(string headerName) : PipelinePolicy + { + private readonly string _headerName = headerName; + private string? _lastValue; + + public string? LastValue => Volatile.Read(ref this._lastValue); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + ProcessNext(message, pipeline, currentIndex); + this.Capture(message); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + this.Capture(message); + } + + private void Capture(PipelineMessage message) + { + if (message.Response is not null && + message.Response.Headers.TryGetValue(this._headerName, out var value) && + !string.IsNullOrEmpty(value)) + { + Volatile.Write(ref this._lastValue, value); + } + } + } + + private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy + { + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.SetHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.SetHeader(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private void SetHeader(PipelineMessage message) + { + message.Request.Headers.Remove(FoundryFeaturesHeader); + message.Request.Headers.Add(FoundryFeaturesHeader, features); + } + } +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 index c683a2a4e3..1896a8407c 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 @@ -38,7 +38,8 @@ $Scenarios = @( 'tool-calling-approval', 'toolbox', 'mcp-toolbox', - 'custom-storage' + 'custom-storage', + 'session-files' ) # Resolve project ARM scope from the endpoint. diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1 index 857ea3be0a..2d938bb013 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1 +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1 @@ -41,14 +41,7 @@ param( [string] $Repository = "foundry-hosting-it", - [string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer", - - # Explicit opt-in for the no-rebuild fast path. CI sets this after running the - # "Build Foundry hosted IT (and its deps)" step, which guarantees the prebuilt - # library DLLs match current source. Off by default so local invocations always - # let publish rebuild ProjectReferences and never produce an image whose tag is - # computed from current source while the contents come from a stale build. - [switch] $UsePrebuiltProjectReferences + [string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer" ) $ErrorActionPreference = "Stop" @@ -107,60 +100,35 @@ if (Test-Path $out) { Remove-Item -Recurse -Force $out } -# Conditionally tell publish to skip rebuilding ProjectReferences and consume the -# prebuilt library DLLs in place. This avoids two failure modes that arise when -# the CI workflow runs a `dotnet build` of the same library projects immediately -# before this script: -# 1) MSB3026 "file is being used by another process" when publish's MSBuild -# tries to overwrite src//bin/Release/net10.0/.dll while the -# previous build's shared-compilation server still holds a file handle. -# 2) Publish needlessly rebuilding identical managed (RID-agnostic) library -# DLLs that prebuild already produced. -# Gated on -UsePrebuiltProjectReferences (a strict opt-in) instead of marker -# detection, because a developer machine may have a stale Release build of the -# libraries from days ago; using those would silently produce an image whose -# content is older than the source the tag is computed from. -$publishExtraArgs = @() -if ($UsePrebuiltProjectReferences) { - Write-Host "-UsePrebuiltProjectReferences: skipping ProjectReference rebuild." -ForegroundColor DarkGray - $publishExtraArgs += "-p:BuildProjectReferences=false" -} else { - # Preflight: in default (rebuild) mode, publish propagates RuntimeIdentifier=linux-musl-x64 - # to library ProjectReferences and writes their intermediates to a RID-suffixed obj path - # (e.g. obj/Release/net10.0/linux-musl-x64/). DefaultItemExcludes follows the new - # IntermediateOutputPath, so any *.AssemblyInfo.cs left in obj/Release/net10.0/ from a - # prior `dotnet build` is no longer excluded and gets picked up by the **/*.cs Compile - # glob, producing CS0579 "duplicate attribute" errors. Detect that state up front and - # tell the user exactly how to recover. - $staleObjProbes = @( - "dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/obj/Release/net10.0", - "dotnet/src/Microsoft.Agents.AI.Foundry/obj/Release/net10.0", - "dotnet/src/Microsoft.Agents.AI/obj/Release/net10.0", - "dotnet/src/Microsoft.Agents.AI.Abstractions/obj/Release/net10.0" - ) - $stale = @($staleObjProbes | Where-Object { Test-Path (Join-Path $_ "*.AssemblyInfo.cs") }) - if ($stale.Count -gt 0) { - $msg = @( - "Detected prior Release/net10.0 build outputs in:" - ($stale | ForEach-Object { " - $_" }) - "" - "Publish would propagate -r linux-musl-x64 to those ProjectReferences and the" - "leftover obj/Release/net10.0/*.AssemblyInfo.cs files would cause CS0579 duplicate" - "attribute errors. Pick one:" - " (a) Pass -UsePrebuiltProjectReferences (skips ProjectReference rebuild and" - " uses the existing src//bin/Release/net10.0/*.dll outputs in place)." - " Only safe when you know those DLLs match current source - this is the path" - " CI uses immediately after its 'Build Foundry hosted IT (and its deps)' step." - " (b) Remove the stale obj/Release trees, e.g.:" - " Remove-Item -Recurse -Force dotnet/src/Microsoft.Agents.AI*/obj/Release" - " and re-run." - ) -join "`n" - throw $msg - } - Write-Host "Letting publish build ProjectReferences (pass -UsePrebuiltProjectReferences in CI to skip)." -ForegroundColor DarkGray +# Always tell publish to skip ProjectReference rebuilds via --no-dependencies. Publish +# resolves TestContainer's framework lib references (Foundry, Foundry.Hosting and their +# transitive deps) by reading the prebuilt DLLs at src//bin/Release/net10.0/*.dll. +# This: +# 1) Structurally avoids the MSB3026 "file is being used by another process" race that +# occurs when publish overwrites the same DLL paths a prior `dotnet build` produced +# while VBCSCompiler from that build still holds file handles. +# 2) Avoids needlessly rebuilding identical managed (RID-agnostic) library DLLs. +# Callers MUST run `dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release` +# (or equivalent) first so those prebuilt DLLs exist. The CI workflow does this in the +# preceding "Build Foundry hosted IT (and its deps)" step. +$prebuildProbes = @( + "dotnet/src/Microsoft.Agents.AI.Foundry/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.dll", + "dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.Hosting.dll" +) +$missingPrebuilds = @($prebuildProbes | Where-Object { -not (Test-Path $_) }) +if ($missingPrebuilds.Count -gt 0) { + $msg = @( + "Required prebuilt outputs not found:" + ($missingPrebuilds | ForEach-Object { " - $_" }) + "" + "Publish runs with --no-dependencies and consumes prebuilt DLLs in place. Build the" + "test project first so its ProjectReference closure populates src//bin/Release/net10.0/:" + " dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release" + ) -join "`n" + throw $msg } -dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out @publishExtraArgs --tl:off | Out-Host +dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false --no-dependencies -o $out --tl:off | Out-Host if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE." } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj index fa28a32494..4a139df1b6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj @@ -9,7 +9,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs index 45d09689ff..01184031e8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs @@ -7,6 +7,7 @@ using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Microsoft.Extensions.AI; @@ -184,7 +185,7 @@ public class FoundryAgentTests // Act: this AsAIAgent path constructs FoundryAgent via its internal // (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring. - var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name")); + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); // Assert Assert.NotNull(agent.GetService()); @@ -398,4 +399,379 @@ public class FoundryAgentTests } #endregion + + #region Agent-endpoint constructor tests + + private const string TestAgentEndpoint = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai"; + private static readonly Uri s_testAgentEndpoint = new(TestAgentEndpoint); + + [Fact] + public void AgentEndpointConstructor_NullEndpoint_ThrowsArgumentNullException() + { + ArgumentNullException ex = Assert.Throws(() => + new FoundryAgent(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider())); + Assert.Equal("agentEndpoint", ex.ParamName); + } + + [Fact] + public void AgentEndpointConstructor_NullCredential_ThrowsArgumentNullException() + { + ArgumentNullException ex = Assert.Throws(() => + new FoundryAgent(agentEndpoint: s_testAgentEndpoint, credential: null!)); + Assert.Equal("credential", ex.ParamName); + } + + [Fact] + public void AgentEndpointConstructor_PopulatesNameAndIdFromEndpointSlug() + { + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider()); + + Assert.Equal("it-happy-path", agent.Name); + Assert.Equal("it-happy-path", agent.Id); + } + + [Fact] + public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull() + { + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider()); + + Assert.NotNull(agent.GetService()); + } + + [Fact] + public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull() + { + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider()); + + Assert.Null(agent.GetService()); + } + + [Fact] + public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + Assert.NotNull(agent.GetService()); + } + + [Fact] + public void AgentEndpointConstructor_AppliesClientFactoryOnce() + { + int count = 0; + FoundryAgent agent = new( + s_testAgentEndpoint, + new FakeAuthenticationTokenProvider(), + clientFactory: c => { count++; return c; }); + + Assert.Equal(1, count); + Assert.NotNull(agent); + } + + [Fact] + public async Task AgentEndpointConstructor_RunAsync_RoutesThroughPerAgentResponsesUrlAsync() + { + Uri? capturedUri = null; + using HttpHandlerAssert handler = new(req => + { + capturedUri = req.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + await agent.RunAsync("Hello"); + + Assert.NotNull(capturedUri); + string path = capturedUri!.AbsolutePath; + Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", path, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("/openai/v1/responses", path, StringComparison.OrdinalIgnoreCase); + Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AgentEndpointConstructor_RunStreamingAsync_RoutesThroughPerAgentResponsesUrlAsync() + { + Uri? capturedUri = null; + bool sawStreamTrue = false; + using HttpHandlerAssert handler = new(async req => + { + capturedUri = req.RequestUri; + if (req.Content is not null) + { + string body = await req.Content.ReadAsStringAsync().ConfigureAwait(false); + if (body.Contains("\"stream\":true", StringComparison.Ordinal)) + { + sawStreamTrue = true; + } + } + + // Minimal SSE response; xUnit assertion only cares about the URL/body shape. + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("data: [DONE]\n\n", Encoding.UTF8, "text/event-stream"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + try + { + await foreach (var _ in agent.RunStreamingAsync("Hello")) + { + // drain + } + } + catch + { + // SSE parse errors are acceptable; we only assert the request shape. + } + + Assert.NotNull(capturedUri); + Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", capturedUri!.AbsolutePath, StringComparison.OrdinalIgnoreCase); + Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase); + Assert.True(sawStreamTrue, "Expected request body to include \"stream\":true."); + } + + [Fact] + public async Task AgentEndpointConstructor_CreateConversationSessionAsync_RoutesThroughProjectLevelUrlAsync() + { + Uri? capturedUri = null; + using HttpHandlerAssert handler = new(req => + { + capturedUri = req.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":\"conv_123\"}", Encoding.UTF8, "application/json"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + try + { + _ = await agent.CreateConversationSessionAsync(); + } + catch + { + // Underlying SDK may attempt extra parsing on the minimal response. We only assert URL routing. + } + + Assert.NotNull(capturedUri); + string path = capturedUri!.AbsolutePath; + Assert.Contains("/api/projects/test-project/openai/v1/conversations", path, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("/agents/", path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AgentEndpointConstructor_StampsMeaiUserAgentHeaderAsync() + { + bool meaiSeen = false; + using HttpHandlerAssert handler = new(req => + { + if (req.Headers.TryGetValues("User-Agent", out var values)) + { + foreach (string v in values) + { + if (v.IndexOf("MEAI/", StringComparison.OrdinalIgnoreCase) >= 0) + { + meaiSeen = true; + } + } + } + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + await agent.RunAsync("Hello"); + + Assert.True(meaiSeen, "Expected MEAI/x.y.z to appear in the User-Agent header on the agent-endpoint pipeline."); + } + + [Fact] + public async Task AgentEndpointConstructor_PassesThroughCallerPolicyOnPerAgentPipelineAsync() + { + // Direct switch to ProjectOpenAIClientOptions means caller-supplied pipeline policies + // (added via AddPolicy) actually flow through to the per-agent traffic. Assert that a + // tag-stamping policy executes on each outbound per-agent request. + bool tagSeen = false; + using HttpHandlerAssert handler = new(req => + { + if (req.Headers.TryGetValues("X-Test-Tag", out var values)) + { + foreach (string v in values) + { + if (v == "tag-1") + { + tagSeen = true; + } + } + } + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + opts.AddPolicy(new HeaderStampPolicy("X-Test-Tag", "tag-1"), PipelinePosition.PerCall); + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + await agent.RunAsync("Hello"); + + Assert.True(tagSeen, "Expected caller-supplied per-call policy to execute on the per-agent pipeline."); + } + + [Fact] + public void AgentEndpointConstructor_OverridesCallerEndpointAndAgentName() + { + // The caller may set Endpoint/AgentName on the options bag; we must override both with + // values derived from agentEndpoint so the URL routing is correct regardless. + ProjectOpenAIClientOptions opts = new() + { + Endpoint = new Uri("https://wrong.example.com/openai/v1"), + AgentName = "wrong-agent", + }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + + Assert.Equal("it-happy-path", agent.Name); + Assert.Equal(s_testAgentEndpoint, opts.Endpoint); + Assert.Equal("it-happy-path", opts.AgentName); + } + + [Fact] + public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient() + { + // The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's + // application-id stamp in the outbound request. Verify the value is propagated onto the + // project-level client's options via the public ProjectOpenAIClient surface. + ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + + ProjectOpenAIClient? projectClient = agent.GetService(); + Assert.NotNull(projectClient); + // Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim. + Assert.Equal("my-app-id", opts.UserAgentApplicationId); + } + + #endregion + + #region ParseAgentEndpoint tests + + [Fact] + public void ParseAgentEndpoint_StandardShape_Parses() + { + var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai")); + Assert.Equal("a1", name); + Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/')); + } + + [Fact] + public void ParseAgentEndpoint_TrailingSlash_Parses() + { + var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai/")); + Assert.Equal("a1", name); + Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/')); + } + + [Fact] + public void ParseAgentEndpoint_UppercaseAgentsSegment_Parses() + { + var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/Agents/a1/endpoint/protocols/openai")); + Assert.Equal("a1", name); + } + + [Fact] + public void ParseAgentEndpoint_SpecialCharsInName_Parses() + { + var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/it-happy_path-1/endpoint/protocols/openai")); + Assert.Equal("it-happy_path-1", name); + } + + [Fact] + public void ParseAgentEndpoint_QueryAndFragmentStripped() + { + var (_, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a/endpoint/protocols/openai?x=1#frag")); + Assert.Equal(string.Empty, root.Query); + Assert.Equal(string.Empty, root.Fragment); + } + + [Fact] + public void ParseAgentEndpoint_SovereignCloudHostNoApiPrefix_Parses() + { + var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.cognitive.microsoft.us/projects/p/agents/a1/endpoint/protocols/openai")); + Assert.Equal("a1", name); + Assert.Equal("https://h.cognitive.microsoft.us/projects/p", root.AbsoluteUri.TrimEnd('/')); + } + + [Fact] + public void ParseAgentEndpoint_MissingAgentsSegment_Throws() + { + ArgumentException ex = Assert.Throws(() => + FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/openai/v1"))); + Assert.Equal("agentEndpoint", ex.ParamName); + } + + [Fact] + public void ParseAgentEndpoint_WrongSuffix_Throws() + { + ArgumentException ex = Assert.Throws(() => + FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a1/openai/v1"))); + Assert.Equal("agentEndpoint", ex.ParamName); + } + + [Fact] + public void ParseAgentEndpoint_EmptyAgentName_Throws() + { + ArgumentException ex = Assert.Throws(() => + FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents//endpoint/protocols/openai"))); + Assert.Equal("agentEndpoint", ex.ParamName); + } + + #endregion + + private sealed class HeaderStampPolicy : PipelinePolicy + { + private readonly string _name; + private readonly string _value; + public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; } + + public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set(this._name, this._value); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set(this._name, this._value); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 3b7176711a..b17efa64f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -1,4 +1,4 @@ - + false @@ -7,7 +7,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/MessageInjectingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/MessageInjectingChatClientTests.cs new file mode 100644 index 0000000000..93f6b02b03 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/MessageInjectingChatClientTests.cs @@ -0,0 +1,493 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for . +/// +public class MessageInjectingChatClientTests +{ + /// + /// Verifies that is resolvable via GetService when the decorator is active. + /// + [Fact] + public void GetService_ReturnsMessageInjectingChatClient_WhenDecoratorActive() + { + // Arrange + Mock mockService = new(); + ChatClientAgent agent = new(mockService.Object, options: new() + { + EnableMessageInjection = true, + }); + + // Act + var injector = agent.ChatClient.GetService(); + + // Assert + Assert.NotNull(injector); + } + + /// + /// Verifies that is null when the decorator is not active. + /// + [Fact] + public void GetService_ReturnsNull_WhenDecoratorNotActive() + { + // Arrange + Mock mockService = new(); + ChatClientAgent agent = new(mockService.Object, options: new()); + + // Act + var injector = agent.ChatClient.GetService(); + + // Assert + Assert.Null(injector); + } + + /// + /// Verifies that messages enqueued on the session before RunAsync are included in the service call messages. + /// + [Fact] + public async Task RunAsync_IncludesInjectedMessages_WhenEnqueuedBeforeCallAsync() + { + // Arrange + List capturedMessages = []; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + capturedMessages.AddRange(msgs)) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }); + + // Create session and enqueue a message directly onto the session's StateBag queue before calling RunAsync + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + var queue = new List(); + queue.Add(new ChatMessage(ChatRole.User, "injected message")); + session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue); + + // Act + await agent.RunAsync([new(ChatRole.User, "original")], session); + + // Assert — the service should have received both the original and injected messages + Assert.Contains(capturedMessages, m => m.Text == "original"); + Assert.Contains(capturedMessages, m => m.Text == "injected message"); + } + + /// + /// Verifies that the queue is drained after a call (messages are not re-delivered on subsequent calls). + /// + [Fact] + public async Task RunAsync_DrainsQueue_MessagesNotRedeliveredAsync() + { + // Arrange + List capturedMessages = []; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + capturedMessages.AddRange(msgs)) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }); + + // Create session and enqueue a message directly onto the session's StateBag queue + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + var queue = new List(); + queue.Add(new ChatMessage(ChatRole.User, "injected once")); + session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue); + + // Act + await agent.RunAsync([new(ChatRole.User, "first call")], session); + + // Assert — the injected message was included in the service call + Assert.Contains(capturedMessages, m => m.Text == "injected once"); + + // Assert — the session's queue is now empty (drained) + Assert.Empty(queue); + } + + /// + /// Verifies that the internal loop fires when no actionable FunctionCallContent is returned + /// but there are pending injected messages in the queue. + /// + [Fact] + public async Task RunAsync_LoopsInternally_WhenNoActionableFCCButPendingMessagesAsync() + { + // Arrange + int serviceCallCount = 0; + Mock mockService = new(); + MessageInjectingChatClient? injectorRef = null; + ChatClientAgentSession? sessionRef = null; + + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + { + serviceCallCount++; + if (serviceCallCount == 1) + { + // First call — simulate that something enqueues a message (e.g., a provider or background task) + injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected during first call")]); + } + + // Return a plain text response (no FunctionCallContent) to trigger the internal loop + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, $"response {serviceCallCount}")])); + }); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }); + + injectorRef = agent.ChatClient.GetService()!; + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + sessionRef = session; + await agent.RunAsync([new(ChatRole.User, "original")], session); + + // Assert — should have made 2 service calls (internal loop triggered by the injected message) + Assert.Equal(2, serviceCallCount); + } + + /// + /// Verifies that the internal loop does NOT fire when the response contains actionable + /// FunctionCallContent, even if there are pending injected messages. + /// + [Fact] + public async Task RunAsync_DoesNotLoopInternally_WhenActionableFCCPresentAsync() + { + // Arrange + int serviceCallCount = 0; + Mock mockService = new(); + MessageInjectingChatClient? injectorRef = null; + ChatClientAgentSession? sessionRef = null; + + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + { + serviceCallCount++; + if (serviceCallCount == 1) + { + // Enqueue a message during the first call + injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]); + // Return a response with an actionable FunctionCallContent + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "myTool", new Dictionary())])])); + } + + // Subsequent calls return plain text (the FCC loop will call back after tool execution) + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")])); + }); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool"); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Tools = [tool] }, + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }, services: new ServiceCollection().BuildServiceProvider()); + + injectorRef = agent.ChatClient.GetService()!; + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + sessionRef = session; + await agent.RunAsync([new(ChatRole.User, "original")], session); + + // Assert — The first service call returned actionable FCC, so no internal injected-message loop + // occurred there. The FCC loop invokes the tool and calls the service again (second call). + // The injected message should be picked up by the second service call (drained at start of + // GetResponseAsync), but no extra internal loop should fire. Exactly 2 service calls expected. + Assert.Equal(2, serviceCallCount); + } + + /// + /// Verifies that the internal loop fires when the response contains only InformationalOnly + /// FunctionCallContent (which are not actionable) and there are pending injected messages. + /// + [Fact] + public async Task RunAsync_LoopsInternally_WhenOnlyInformationalOnlyFCCAndPendingMessagesAsync() + { + // Arrange + int serviceCallCount = 0; + Mock mockService = new(); + MessageInjectingChatClient? injectorRef = null; + ChatClientAgentSession? sessionRef = null; + + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + { + serviceCallCount++; + if (serviceCallCount == 1) + { + // Enqueue a message during the first call + injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]); + // Return a response with InformationalOnly FCC (not actionable) + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "myTool", new Dictionary()) { InformationalOnly = true }])])); + } + + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")])); + }); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }); + + injectorRef = agent.ChatClient.GetService()!; + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + sessionRef = session; + await agent.RunAsync([new(ChatRole.User, "original")], session); + + // Assert — InformationalOnly FCC is NOT actionable, so internal loop should trigger + Assert.Equal(2, serviceCallCount); + } + + /// + /// Verifies that when the inner client returns a ConversationId on the first call, the + /// MessageInjectingChatClient propagates it to options on subsequent loop iterations. + /// + [Fact] + public async Task RunAsync_PropagatesConversationId_AcrossInternalLoopIterationsAsync() + { + // Arrange + int serviceCallCount = 0; + List capturedConversationIds = []; + MessageInjectingChatClient? injectorRef = null; + ChatClientAgentSession? sessionRef = null; + + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable _, ChatOptions? opts, CancellationToken _) => + { + serviceCallCount++; + capturedConversationIds.Add(opts?.ConversationId); + + if (serviceCallCount == 1) + { + // First call: inject a message and return a ConversationId + injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]); + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "first response")]) + { + ConversationId = "conv-123", + }); + } + + // Second call (from loop): should have the propagated ConversationId + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "second response")])); + }); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + EnableMessageInjection = true, + }, services: new ServiceCollection().BuildServiceProvider()); + + injectorRef = agent.ChatClient.GetService()!; + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + sessionRef = session; + await agent.RunAsync([new(ChatRole.User, "hello")], session); + + // Assert — The second call should have received the ConversationId propagated from the first response + Assert.Equal(2, serviceCallCount); + Assert.Null(capturedConversationIds[0]); // First call: no ConversationId yet + Assert.Equal("conv-123", capturedConversationIds[1]); // Second call: propagated from first response + } + + /// + /// Verifies that a session with pending injected messages can be serialized and deserialized, + /// and that the deserialized session correctly delivers the injected messages on the next run. + /// + [Fact] + public async Task RunAsync_DeliversInjectedMessages_AfterSessionSerializationRoundTripAsync() + { + // Arrange + List capturedMessagesFirstRun = []; + List capturedMessagesSecondRun = []; + int runCount = 0; + Mock mockService = new(); + MessageInjectingChatClient? injectorRef = null; + ChatClientAgentSession? sessionRef = null; + + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable msgs, ChatOptions? _, CancellationToken _) => + { + if (runCount == 1) + { + capturedMessagesFirstRun.AddRange(msgs); + + // Inject a message during the first run — this will remain pending (not drained) + // because we return an actionable FCC that causes the parent loop to take over. + injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected before serialization")]); + + // Return actionable FCC so the injection loop does NOT drain the message + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "myTool", new Dictionary())])])); + } + + // Second run (after deserialization) — capture what messages come through + capturedMessagesSecondRun.AddRange(msgs); + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")])); + }); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool"); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Tools = [tool] }, + ChatHistoryProvider = mockChatHistoryProvider.Object, + RequirePerServiceCallChatHistoryPersistence = true, + EnableMessageInjection = true, + }, services: new ServiceCollection().BuildServiceProvider()); + + injectorRef = agent.ChatClient.GetService()!; + + // Act — First run: inject a message that stays pending + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + sessionRef = session; + runCount = 1; + await agent.RunAsync([new(ChatRole.User, "first run message")], session); + + // Serialize the session and deserialize into a new instance + var serialized = await agent.SerializeSessionAsync(session!); + var deserializedSession = await agent.DeserializeSessionAsync(serialized) as ChatClientAgentSession; + + // Second run on the deserialized session — the injected message should be delivered + runCount = 2; + sessionRef = deserializedSession; + await agent.RunAsync([new(ChatRole.User, "second run message")], deserializedSession); + + // Assert — the second run should include the injected message from before serialization + Assert.Contains(capturedMessagesSecondRun, m => m.Text == "injected before serialization"); + Assert.Contains(capturedMessagesSecondRun, m => m.Text == "second run message"); + } +}