From 9faf52de4f0632882d6e0bb2212393b548d5f68a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 11 May 2026 12:56:58 +0100 Subject: [PATCH] .NET: Hosted-Files sample + AgentSessionFiles SDK companion + integration test (#5698) * .NET: Add Hosted-Files sample + alpha AgentSessionFiles SDK companion + integration test Closes #5691 - Hosted-Files server sample (mirrors python 06_files): 3 local tools reading the per-session \C:\Users\rbarreto sandbox volume. - SessionFilesClient REPL companion: code-first equivalent of zd ai agent files upload using the alpha Azure.AI.Projects.AgentSessionFiles SDK (upload/ls/download/rm + session lifecycle with isolation key). - session-files scenario added to the Foundry.Hosting.IntegrationTests multi-scenario harness (PR #5598): SessionFilesHostedAgentFixture + SessionFilesHostedAgentTests.UploadAndAgentReadsFileAsync, end-to-end validating upload then agent-reads-file (agent_session_id pinned via CreateResponseOptions.Patch). Bundled testdata is linked from the sample so there is a single source of truth. * .NET: Hosted-Files: REPL companion now demonstrates file-as-knowledge end-to-end Adds an 'ask ' command to SessionFilesClient that pins agent_session_id (via CreateResponseOptions.Patch) so the agent invoked from the REPL reads files this REPL just uploaded. Surfaces the file content as agent knowledge in the same in-process loop instead of telling the user to shell out to azd ai agent invoke. * .NET: Reshape Hosted-Files sample - bake files into image, SessionFilesClient becomes thin chat REPL The previous SessionFilesClient leaned on the alpha AgentSessionFiles SDK to upload files at runtime, which made it diverge from the canonical Using-Samples shape (SimpleAgent / SimpleInvocationsAgent: tiny chat REPLs). This change: - Bakes the sample resources/ directory into the published output via a Content Include in HostedFiles.csproj. Inside the container the files live at /app/resources/. Two local function tools (ListFiles, ReadFile) surface them to the model. - Reshapes SessionFilesClient as a thin FoundryAgent chat REPL, identical shape to SimpleAgent. AGENT_ENDPOINT + AGENT_NAME, that is it. - Demo flow: user asks 'Give me the total revenue in the contoso file' and the agent answers with the figure read from its bundled file. Validated end-to-end locally against Hosted-Files on http://localhost:60419. - Bypasses SampleEnvironment alias on optional env vars to avoid stdin prompts when running unattended. The Foundry.Hosting.IntegrationTests session-files scenario continues to validate the alpha AgentSessionFiles SDK end-to-end (upload + agent reads from session HOME) and is unchanged. * .NET: Foundry.Hosting.IntegrationTests TestContainer - constrain session-files tools to $HOME Addresses the path-traversal review comment on the session-files scenario: ResolveSessionPath in TestContainer used to allow absolute paths and .. traversals, which (when chained with indirect prompt injection in an uploaded file) would let the model read or list arbitrary container files via the ReadFile / ListFiles tools. Mirrors the canonicalize + StartsWith(home) pattern from the framework's own FileSystemAgentFileStore.ResolveSafePath: rejects rooted paths, calls Path.GetFullPath, and verifies the result stays under $HOME, throwing ArgumentException otherwise. The Hosted-Files sample is already safe (uses Path.GetFileName which strips any directory component) so no change there. The integration test continues to upload and read 'contoso_q1_2026_report.txt', a single relative filename which passes the new validation unchanged. * .NET: SessionFilesHostedAgentTests - shrink to alpha SDK round-trip The previous test attempted to pin agent_session_id into the /responses payload via JsonPatch so the agent would read the file uploaded through AgentSessionFiles. The Foundry alpha service now consistently rejects the explicit-session-id pin with HTTP 400 conflict on /responses, regardless of whether the session was pre-created via AgentAdministrationClient or left to be auto-provisioned, so the agent leg of the test is no longer reachable from the SDK surface. Reshape the test to exercise what the alpha SDK actually guarantees: create session, upload, list (assert presence + size), download (assert deterministic token), delete (assert removed), cleanup. Everything stays inside Azure.AI.Projects.Agents.AgentSessionFiles. Verified live against tao-foundry-prj: UploadListDownloadAndDeleteAsync passed in 30s. Full Foundry.Hosting.IntegrationTests run: 25 total, 6 passed, 19 skipped (existing placeholders), 0 failed. * .NET: SessionFilesHostedAgentTests - rewrite as upload-then-FoundryAgent.RunAsync e2e Per review feedback the integration test must validate the hosted agent itself: client uploads a file via the alpha AgentSessionFiles SDK, then FoundryAgent.RunAsync invokes the deployed agent and the agent's container-side ReadFile tool surfaces the uploaded file content into the response. Test flow: 1. agent.RunAsync(warmup) - platform provisions a per-session container. 2. AgentAdministrationClient.GetSessionsAsync(latest) - resolve the just-provisioned agent_session_id. 3. AgentSessionFiles.UploadSessionFileAsync - upload contoso file to that session, asserts BytesWritten + GetSessionFiles listing. 4. agent.RunAsync(real prompt, options=PreviousResponseId chain) - chained to warmup so the platform routes back to the same container. 5. Assert response contains '1,482.6' (deterministic token from file). 6. Best-effort cleanup. The test is annotated with [Fact(Skip=...)] right now: the Foundry alpha service consistently returns HTTP 400 conflict on /responses requests that link to a prior session via previous_response_id, conversation_id, or agent_session_id pinning - verified across multiple retries with multiple chaining strategies. Without that link we cannot route the second invocation to the same container the file was uploaded to. When the platform regression is resolved, removing the Skip will exercise the full flow. Full Foundry.Hosting.IntegrationTests run with this change: 25 total, 5 passed, 20 skipped (existing placeholders + this one), 0 failed. * .NET: SessionFilesHostedAgentTests - end-to-end upload-then-FoundryAgent.RunAsync now passes The blocker was a routing problem combined with a platform race: 1. Routing two /responses calls to the same per-session container. - agent_session_id pin in body -> 400 (platform treats it as create) - conversation_id created at project root -> 404 at agent endpoint - previous_response_id chain -> different session The working answer is to create the conversation on a per-agent ProjectOpenAIClient (AgentName option, URL becomes /agents/{name}/endpoint/protocols/openai/conversations) and pass that conversation_id on both calls. Both then resolve to the SAME x-agent-session-id (verified by capturing the response header). 2. Race after AgentSessionFiles upload. The upload mutates session/ conversation revision; a /responses call issued immediately after 400-conflicts with 'modified concurrently. Please retry.' Bounded exponential retry handles it (5 attempts, 2*attempt seconds). Test flow: 1. Create per-agent OpenAI client + ProjectConversationsClient + ProjectResponsesClient. 2. CreateProjectConversationAsync on the per-agent client. 3. Warm-up agent.RunAsync(prompt, ChatOptions { ConversationId = ... }) - captures x-agent-session-id from the response header via a custom pipeline policy. 4. AgentSessionFiles.UploadSessionFileAsync to that session id. 5. ProjectResponsesClient.CreateResponseAsync (raw, retry-on-conflict) with the same conversation_id -> routes back to the same container. 6. Assert response contains '1,482.6' (deterministic token from file). 7. Cleanup: delete file, leave session for TTL. Verified live against tao-foundry-prj: UploadedFile_IsReadByHostedAgentAsync passed in 24.9s. Full Foundry.Hosting.IntegrationTests run: 25 total, 6 passed, 19 skipped (existing placeholders), 0 failed. * .NET: address Copilot PR review findings - agent.manifest.yaml: description + tags now reflect bundled-files agent (image-baked /app/resources), not the obsolete session-sandbox tools the prior shape claimed. - SessionFilesHostedAgentTests: wrap test body in try/finally to call DeleteConversationAsync on the conversation we created (matches HappyPathHostedAgentTests pattern; prevents conversation leakage across runs). - ResponseHeaderCapturePolicy: drop unused LastRequestBody capture left over from diagnosis. Test still passes live (40s). * .NET: Hosted-Files: split into bundled vs session-file tool pairs The previous Hosted-Files agent only exposed bundled (image-baked) file knowledge. The platform also surfaces session-uploaded files at \C:\Users\rbarreto inside the per-session container per container-image-spec.md line 172 (verified live by SessionFilesHostedAgentTests). The sample now teaches both patterns. Two distinct tool pairs, each scoped to its own root: Bundled (image-baked): ListBundledFiles, ReadBundledFile -> /app/resources/ (BUNDLED_FILES_DIR override) Session-uploaded (\C:\Users\rbarreto): ListSessionFiles, ReadSessionFile -> \C:\Users\rbarreto (default /home/session per container spec) Security model -- distinct tools, distinct sandboxes: - Tool input is a fileName, not a path. Schema-level: model cannot request directories or traversals. - Path.GetFileName(input) strips any directory components. - Path.GetFullPath + StartsWith(root) check rejects anything outside the tool's root, mirroring FileSystemAgentFileStore.ResolveSafePath. - Read-only, non-recursive listing. No glob, no '..'. - Failures non-revealing: 'File not found in .' The two roots are physically isolated (image-baked vs platform-mounted per-session volume). A bundled-root tool can never reach a session file and vice-versa, even if the implementation has a bug. README updated to document both flows, the security pattern, and cite the container-image-spec.md line 172 contract for \C:\Users\rbarreto. Live IT SessionFilesHostedAgentTests.UploadedFile_IsReadByHostedAgentAsync re-passed in 42s after the change (TestContainer is unchanged; the sample-agent split does not affect the IT). * .NET: Hosted-Files README - fix broken relative link to IT (4..5 dots) --- dotnet/agent-framework-dotnet.slnx | 4 + .../responses/Hosted-Files/.dockerignore | 6 + .../responses/Hosted-Files/.env.example | 5 + .../responses/Hosted-Files/Dockerfile | 17 ++ .../Hosted-Files/Dockerfile.contributor | 19 ++ .../responses/Hosted-Files/HostedFiles.csproj | 40 +++ .../responses/Hosted-Files/Program.cs | 223 ++++++++++++++++ .../responses/Hosted-Files/README.md | 128 ++++++++++ .../Hosted-Files/agent.manifest.yaml | 30 +++ .../responses/Hosted-Files/agent.yaml | 9 + .../resources/contoso_q1_2026_report.txt | 121 +++++++++ .../SessionFilesClient/Program.cs | 116 +++++++++ .../SessionFilesClient/README.md | 50 ++++ .../SessionFilesClient.csproj | 24 ++ .../Program.cs | 96 +++++++ .../SessionFilesHostedAgentFixture.cs | 17 ++ .../Foundry.Hosting.IntegrationTests.csproj | 8 + .../README.md | 1 + .../SessionFilesHostedAgentTests.cs | 238 ++++++++++++++++++ .../scripts/it-bootstrap-agents.ps1 | 3 +- 20 files changed, 1154 insertions(+), 1 deletion(-) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.dockerignore create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/resources/contoso_q1_2026_report.txt create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/SessionFilesHostedAgentFixture.cs create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs 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/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/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/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.