diff --git a/docs/decisions/0026-hosted-session-identity-context.md b/docs/decisions/0026-hosted-session-identity-context.md new file mode 100644 index 0000000000..4d03e669b2 --- /dev/null +++ b/docs/decisions/0026-hosted-session-identity-context.md @@ -0,0 +1,84 @@ +--- +status: accepted +contact: rogerbarreto +date: 2026-05-07 +deciders: rogerbarreto +consulted: [] +informed: [] +--- + +# Hosted session identity context for Foundry Hosting + +## Context and Problem Statement + +Server-hosted Foundry agents need a way to scope per-user state (most notably `FoundryMemoryProvider` memories) by the end user that initiated the request. The Foundry platform already injects `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every Responses request, but the agent-framework hosting layer did not surface those values to `AIContextProvider` instances. The provider's `stateInitializer` only received an `AgentSession?` with no identity attached, so per-user scoping was impossible without out-of-band plumbing. + +## Decision Drivers + +- Memory and any future user-private context must be partitioned per end user without per-sample boilerplate. +- The identity must be **read-only** from the perspective of `AIContextProvider`s, so a buggy or hostile provider cannot escalate or leak across users. +- The persisted session must validate against the live request on every resume to defend against session-id leak and in-process tampering. +- The change must work for every existing hosted-agent type (`ChatClientAgent`, `FoundryAgent`, future ones) without per-type refactoring of cast-heavy code paths in `Microsoft.Agents.AI`. +- Local Docker debugging must remain possible when the platform headers are absent. + +## Considered Options + +1. **`HostedSessionContext` stored in `AgentSessionStateBag`, exposed via a public read accessor and an `internal` setter.** Hosting writes once on session creation and validates on every resume. +2. **Specialised `HostedAgentSession : AgentSession` wrapper** that carries `UserId`/`ChatId` properties, with `GetService()` as the unwrap escape hatch. +3. **New property on `AgentSession` base class** (`HostedSessionContext? HostedContext { get; internal set; }`). +4. **AsyncLocal middleware** that reads the headers and stuffs them into a per-request `AsyncLocal` consumed by the provider. + +For the source of identity: +- A. The platform-injected `IsolationContext` exposed by `ResponseContext.Isolation` (typed `UserIsolationKey`/`ChatIsolationKey`). +- B. The OpenAI Responses spec's top-level `request.User` field. +- C. A custom HTTP header `x-client-user`. + +## Decision Outcome + +**Option 1** was chosen for the storage shape, sourced from **Option A** (`ResponseContext.Isolation`). + +Rationale: + +- **Wrapper rejected (Option 2).** `ChatClientAgentSession` is `sealed` and `ChatClientAgent` rejects any other session type via direct `is not ChatClientAgentSession` checks at multiple call sites. Wrapping would force non-trivial refactors across `Microsoft.Agents.AI` and a corresponding repeat for every other agent type. +- **Base-class property rejected (Option 3).** Leaks "hosted" semantics into the universal `AgentSession` abstraction used by Durable, A2A, and CopilotStudio agents that have no notion of a hosted user. +- **AsyncLocal rejected (Option 4).** Surfaces the concept only locally, requires every consumer to re-implement the bridge, and cannot be enforced as read-only. +- **`request.User` rejected (Option B).** Set by the caller, not the platform. Forging it client-side trivially defeats per-user partitioning. +- **`x-client-user` rejected (Option C).** Non-standard, requires custom HTTP plumbing, and duplicates the platform-provided isolation contract. + +Implementation summary in `Microsoft.Agents.AI.Foundry.Hosting`: + +| Type | Visibility | Purpose | +|---|---|---| +| `HostedSessionContext` | public sealed | Captures `UserId` and `ChatId` (both required, non-whitespace). | +| `HostedSessionContextExtensions.GetHostedContext` | public | Read accessor for `AIContextProvider`s. | +| `HostedSessionContextExtensions.SetHostedContext` | internal | Writer reserved for the hosting assembly. Backed by `AgentSessionStateBag` under a well-known key for serialisation. | +| `HostedSessionIsolationKeyProvider` (abstract) | public | DI-resolvable factory. Async signature: `ValueTask GetKeysAsync(ResponseContext, CreateResponse, CancellationToken)`. | +| `PlatformHostedSessionIsolationKeyProvider` | internal sealed | Default implementation. Maps `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. Returns `null` when either is absent. | + +Behaviour added to `AgentFrameworkResponseHandler.CreateAsync`: + +1. Resolve `HostedSessionIsolationKeyProvider` from DI; fall back to `PlatformHostedSessionIsolationKeyProvider`. +2. Call `GetKeysAsync(context, request, cancellationToken)`. A `null` result throws `InvalidOperationException` (becomes 500). A null/whitespace `UserId` or `ChatId` is rejected by `HostedSessionContext`'s constructor. +3. Branch on the **session's existing context**, not on whether a `conversation_id` was supplied: + - **No session (`session is null`):** nothing to stamp; skip. + - **Session present but un-stamped (`GetHostedContext() is null`):** treat as fresh. This covers both newly-created sessions and pre-existing sessions whose `conversation_id` was provisioned externally (e.g. via `conversations.CreateProjectConversationAsync()`) before the first hosted-agent request. Stamp the resolved identity now. + - **Session present with stamped context:** strict resume. The persisted `UserId` and `ChatId` must equal the resolved values exactly. Mismatch throws `ResponsesApiException` with status 403 and body `Hosted session identity context mismatch`. + +## Consequences + +Positive: + +- Per-user memory partitioning works out of the box for any agent that consumes a `Microsoft.Agents.AI.Foundry.FoundryMemoryProvider` configured to read `session.GetHostedContext().UserId`. +- Cross-user session-id leak and in-process tampering of the persisted identity both surface as a 403 with a deliberately uninformative body. +- The identity is opaque to the framework, matching the platform's semantics. The framework never inspects user identity; the `IsolationContext` keys are pre-partitioned per agent. + +Negative: + +- Every existing hosted sample fails locally without a `HostedSessionIsolationKeyProvider` registered, because the platform headers are absent outside the platform. Mitigated by shipping `Hosted_Shared_Contributor_Setup` with `DevTemporaryLocalSessionIsolationKeyProvider` and `AddDevTemporaryLocalContributorSetup`, and migrating all 9 existing responses samples. +- An attacker who can plant an un-stamped session under a victim's `conversation_id` *before* the victim's first hosted-agent request would be stamped with the attacker's identity on that first request. This is not a regression vs. behaviour without this contract, and is mitigated in practice because the `conversation_id` namespace is allocated by the platform per project. Once a session is stamped, the strict equality check fully defends the resume path. + +## Out of scope + +- Per-request `User` field on `CreateResponse` is intentionally not consumed; only the platform `IsolationContext` headers carry trustworthy identity. +- Generic (non-Foundry) hosting layers can re-define an equivalent type if needed; nothing in this ADR is moved into `Microsoft.Agents.AI.Hosting` because `Microsoft.Agents.AI.Foundry.Hosting` does not depend on it. +- HMAC tamper signatures over the persisted context are not implemented; comparison against `ResponseContext.Isolation` on every request is sufficient because the platform sets those headers at the trust boundary. diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c684fcf883..48313e895e 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -325,9 +325,15 @@ + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj index 0bccd317f6..10469c3d7f 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj @@ -18,6 +18,7 @@ + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs new file mode 100644 index 0000000000..af1115bf32 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Hosted-MemoryAgent +// +// Demonstrates how to host an agent that uses FoundryMemoryProvider so that user-private memories +// persist across requests and across sessions, scoped per user via the Foundry platform's +// isolation key headers. +// +// Memory scope flows from request -> hosting layer -> session -> provider: +// 1. Foundry sets x-agent-user-isolation-key on every inbound request. +// 2. AgentFrameworkResponseHandler reads context.Isolation.UserIsolationKey via the registered +// HostedSessionIsolationKeyProvider and stores it on the session as a HostedSessionContext. +// 3. FoundryMemoryProvider's stateInitializer reads HostedSessionContext.UserId and uses it as +// the FoundryMemoryProviderScope, partitioning memories per user. + +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Hosted_Shared_Contributor_Setup; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; + +// Load .env file if present (for local development). +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); +var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +var embeddingDeployment = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002"; +var memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "hosted-memory-sample"; + +// 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 foundry). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +AIProjectClient projectClient = new(projectEndpoint, credential); + +// FoundryMemoryProvider partitions memories per end user via a built-in HostedFoundryMemoryProviderScopes +// helper that reads the platform-injected user isolation key from the HostedSessionContext that the +// hosting layer placed on the session. +FoundryMemoryProvider memoryProvider = new( + projectClient, + memoryStoreName, + stateInitializer: HostedFoundryMemoryProviderScopes.PerUser()); + +// Provision the memory store on startup if it does not already exist. EnsureMemoryStoreCreatedAsync +// is idempotent. Doing this once at start avoids per-request latency. +await memoryProvider.EnsureMemoryStoreCreatedAsync(deployment, embeddingDeployment, "Memory store for the hosted travel-assistant sample."); + +const string AgentInstructions = """ + You are a friendly travel assistant. When the user shares trip preferences, destinations, + travel companions, or constraints, remember them and use them in later turns. Use known + memories about the user when responding, and do not invent details. + """; + +ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions() +{ + Name = agentName, + ChatOptions = new ChatOptions + { + ModelId = deployment, + Instructions = AgentInstructions + }, + AIContextProviders = [memoryProvider] +}); + +// Host the agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production. + +var app = builder.Build(); +app.MapFoundryResponses(); + +// In Development, also map the OpenAI-compatible route that AIProjectClient uses. +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md new file mode 100644 index 0000000000..d9b3a11825 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md @@ -0,0 +1,155 @@ +# Hosted-MemoryAgent + +A hosted Foundry agent that uses **FoundryMemoryProvider** to remember user-private details across +requests and across sessions, scoped per end user via the Foundry platform's isolation keys. The +agent plays a friendly travel assistant: tell it about your trip, ask follow-up questions in a new +session, and it recalls what it learned about you. + +This sample exists to demonstrate two things together: + +1. How to host an agent that consumes a `Microsoft.Extensions.AI.AIContextProvider` (specifically + `FoundryMemoryProvider`) under the Foundry Responses hosting layer. +2. How the new `HostedSessionContext` flows from the `Foundry` platform isolation headers + (`x-agent-user-isolation-key`, `x-agent-chat-isolation-key`) through the + `HostedSessionIsolationKeyProvider` into the provider's `stateInitializer`, so memories are + partitioned per user automatically. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with at least one chat model deployment and one embedding model deployment +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your values: + +```bash +cp .env.example .env +``` + +Required: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002 +AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample +AGENT_NAME=hosted-memory-agent +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +``` + +For local container runs only (the platform supplies these in production): + +```env +HOSTED_USER_ISOLATION_KEY=alice +HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 +``` + +> `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## How memory scoping works + +| Layer | Source of the user identity | +|---|---| +| Inbound request | The Foundry platform sets `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every request. | +| Hosting layer | `AgentFrameworkResponseHandler` resolves a `HostedSessionIsolationKeyProvider` from DI and calls `GetKeysAsync(context, request, ct)`. The default implementation reads `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. | +| Session | The handler stores the resolved values on the session as a `HostedSessionContext` on the first request, and validates the values on every subsequent request that resumes the same conversation (mismatch returns 403). | +| Memory provider | The sample's `stateInitializer` reads `session.GetHostedContext().UserId` and uses it as the `FoundryMemoryProviderScope`. Memories are partitioned per user. | + +When running outside the Foundry platform the headers are absent. The sample registers +`DevTemporaryLocalSessionIsolationKeyProvider` (via `AddDevTemporaryLocalContributorSetup`) which +falls back to the `HOSTED_USER_ISOLATION_KEY` and `HOSTED_CHAT_ISOLATION_KEY` environment variables, +defaulting to a single `local-dev-*` bucket when neither is set. + +> **Production warning.** Never register `DevTemporaryLocalSessionIsolationKeyProvider` in +> production. The Foundry platform sets the isolation keys for every inbound request, and +> client-supplied environment variables can be forged. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent +dotnet run +``` + +The agent starts on `http://localhost:8088`. + +### Test it + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.", "model": "hosted-memory-agent"}' +``` + +Wait a few seconds for memory extraction, then ask a follow-up using the response id from the +previous call as `previous_response_id`: + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "What do you already know about my upcoming trip?", "previous_response_id": "", "model": "hosted-memory-agent"}' +``` + +## Running with Docker + +Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies +outside this folder. Use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-memory-agent . +``` + +### 3. Run the container + +```bash +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-memory-agent \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + -e HOSTED_USER_ISOLATION_KEY=alice \ + -e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \ + --env-file .env \ + hosted-memory-agent +``` + +### 4. Smoke test the running container + +A scripted smoke test that exercises memory recall and per-user isolation across two simulated +users is provided at `scripts/smoke.ps1`. From the sample folder: + +```powershell +pwsh ./scripts/smoke.ps1 +``` + +The script publishes the project, builds the image, runs the container with two distinct +`HOSTED_USER_ISOLATION_KEY` values, drives a multi-turn conversation per user, asserts that each +user only sees their own memories, and exits non-zero on failure. + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the +standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in +`HostedMemoryAgent.csproj` for the `PackageReference` alternative. + +## How it differs from sibling samples + +| | Hosted-ChatClientAgent | Hosted-MemoryAgent | +|---|---|---| +| **Agent definition** | Inline (`AsAIAgent(model, instructions)`) | Inline, plus `AIContextProviders = [memoryProvider]` | +| **State** | None beyond the conversation history | Per-user memories persisted in Foundry Memory | +| **Identity** | Not used | Required: `HostedSessionContext.UserId` flows into the memory scope | +| **Local dev** | `AddDevTemporaryLocalContributorSetup()` keeps requests succeeding when isolation headers are absent | Same; additionally honours `HOSTED_USER_ISOLATION_KEY` to simulate distinct users | diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.manifest.yaml new file mode 100644 index 0000000000..8ff9e566c5 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.manifest.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-memory-agent +displayName: "Hosted Memory Agent" + +description: > + A travel-assistant hosted agent that uses FoundryMemoryProvider to remember user-private + preferences and details across sessions. Memory is scoped per end user via the Foundry + platform's isolation key headers. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming + - Agent Framework + - Memory + - Foundry Memory + +template: + name: hosted-memory-agent + 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-MemoryAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.yaml new file mode 100644 index 0000000000..f7b65589a4 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/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-memory-agent +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/scripts/smoke.ps1 b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/scripts/smoke.ps1 new file mode 100644 index 0000000000..4f85fb3873 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/scripts/smoke.ps1 @@ -0,0 +1,110 @@ +#requires -Version 7 +<# +.SYNOPSIS + Local smoke test for the Hosted-MemoryAgent sample. +.DESCRIPTION + Publishes the sample, builds the contributor Docker image, runs the container twice with two + distinct HOSTED_USER_ISOLATION_KEY values, drives a multi-turn conversation per user via curl + invocations, and asserts that each user only sees their own remembered details. + Exits non-zero on failure. + + Prerequisites: + - Docker + - az login (token is fetched from the host) + - .env populated with AZURE_AI_PROJECT_ENDPOINT and model deployments +.NOTES + This script is for local Docker debugging only. The Foundry platform supplies the isolation + keys for every inbound request in production and the dev fallback used here must not be + enabled in production deployments. +#> + +[CmdletBinding()] +param( + [int]$Port = 8088, + [string]$ImageName = 'hosted-memory-agent-smoke', + [int]$RecallDelaySeconds = 25 +) + +$ErrorActionPreference = 'Stop' +Set-Location -Path $PSScriptRoot/.. + +if (-not (Test-Path .env)) { + throw '.env not found. Copy .env.example to .env and fill in AZURE_AI_PROJECT_ENDPOINT.' +} + +Write-Host '==> Publishing sample for linux-musl-x64 ...' +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out --tl:off | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' } + +Write-Host '==> Building docker image ...' +docker build -f Dockerfile.contributor -t $ImageName . | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'docker build failed.' } + +Write-Host '==> Fetching bearer token ...' +$bearer = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv +if (-not $bearer) { throw 'Failed to obtain bearer token. Run az login.' } + +function Start-Container([string]$UserKey, [string]$ChatKey, [string]$ContainerName) { + docker rm -f $ContainerName 2>$null | Out-Null + docker run -d --name $ContainerName -p ${Port}:8088 ` + -e AGENT_NAME=hosted-memory-agent ` + -e AZURE_BEARER_TOKEN=$bearer ` + -e HOSTED_USER_ISOLATION_KEY=$UserKey ` + -e HOSTED_CHAT_ISOLATION_KEY=$ChatKey ` + --env-file .env ` + $ImageName | Out-Host + if ($LASTEXITCODE -ne 0) { throw "docker run failed for $ContainerName." } + # Wait briefly for the listener to come up. + Start-Sleep -Seconds 6 +} + +function Invoke-Agent([string]$Prompt, [string]$PreviousResponseId = $null) { + $body = @{ input = $Prompt; model = 'hosted-memory-agent' } + if ($PreviousResponseId) { $body['previous_response_id'] = $PreviousResponseId } + $json = $body | ConvertTo-Json -Compress + $resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Body $json + return $resp +} + +function Assert-Contains([string]$Haystack, [string]$Needle, [string]$Label) { + if ($Haystack -notmatch [regex]::Escape($Needle)) { + throw "FAILED [$Label]: expected response to contain '$Needle' but got: $Haystack" + } + Write-Host "PASS [$Label]: response contains '$Needle'." +} + +function Assert-NotContains([string]$Haystack, [string]$Needle, [string]$Label) { + if ($Haystack -match [regex]::Escape($Needle)) { + throw "FAILED [$Label]: response unexpectedly contains '$Needle': $Haystack" + } + Write-Host "PASS [$Label]: response does not contain '$Needle'." +} + +try { + Write-Host '==> Phase 1: alice teaches the agent her trip details ...' + Start-Container -UserKey 'alice' -ChatKey 'alice-chat-1' -ContainerName 'hosted-memory-smoke-alice' + $r1 = Invoke-Agent -Prompt 'Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.' + $r2 = Invoke-Agent -Prompt 'I am travelling with my sister and we love finding scenic viewpoints.' -PreviousResponseId $r1.id + + Write-Host "==> Waiting $RecallDelaySeconds s for memory extraction ..." + Start-Sleep -Seconds $RecallDelaySeconds + + $r3 = Invoke-Agent -Prompt 'What do you already know about my upcoming trip?' -PreviousResponseId $r2.id + $aliceText = ($r3.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' ' + Assert-Contains $aliceText 'Patagonia' 'alice recall: Patagonia' + + docker rm -f hosted-memory-smoke-alice | Out-Null + + Write-Host '==> Phase 2: bob starts a fresh container with a different user isolation key ...' + Start-Container -UserKey 'bob' -ChatKey 'bob-chat-1' -ContainerName 'hosted-memory-smoke-bob' + $b1 = Invoke-Agent -Prompt 'Hello, what trip am I planning?' + $bobText = ($b1.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' ' + Assert-NotContains $bobText 'Patagonia' 'bob isolation: no leak of alice memories' + + Write-Host '' + Write-Host '==> All smoke assertions passed.' +} +finally { + docker rm -f hosted-memory-smoke-alice 2>$null | Out-Null + docker rm -f hosted-memory-smoke-bob 2>$null | Out-Null +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj index 0029f66e39..899ed960ce 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj @@ -20,6 +20,7 @@ +