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 @@ "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.