diff --git a/docs/decisions/0026-hosted-session-identity-context.md b/docs/decisions/0026-hosted-session-identity-context.md index 4d03e669b2e..9612ba3af21 100644 --- a/docs/decisions/0026-hosted-session-identity-context.md +++ b/docs/decisions/0026-hosted-session-identity-context.md @@ -1,7 +1,7 @@ --- -status: accepted +status: superseded by [ADR-0030](0030-hosted-platform-context-agentserver-2.0.md) contact: rogerbarreto -date: 2026-05-07 +date: 2026-06-29 deciders: rogerbarreto consulted: [] informed: [] @@ -9,6 +9,8 @@ informed: [] # Hosted session identity context for Foundry Hosting +> **Superseded by [ADR-0030](0030-hosted-platform-context-agentserver-2.0.md).** `Azure.AI.AgentServer.*` 2.0.0 (responses protocol `2.0.0`) replaced `ResponseContext.Isolation` (`UserIsolationKey` / `ChatIsolationKey`, headers `x-agent-user-isolation-key` / `x-agent-chat-isolation-key`) with `ResponseContext.PlatformContext` (`UserIdKey` / `CallId`, headers `x-agent-user-id` / `x-agent-foundry-call-id`). The chat isolation key was removed and `HostedSessionContext` is now user-only. This ADR is retained as the historical record of the original design. + ## 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. diff --git a/docs/decisions/0030-hosted-platform-context-agentserver-2.0.md b/docs/decisions/0030-hosted-platform-context-agentserver-2.0.md new file mode 100644 index 00000000000..55d99c97c56 --- /dev/null +++ b/docs/decisions/0030-hosted-platform-context-agentserver-2.0.md @@ -0,0 +1,84 @@ +--- +status: accepted +contact: rogerbarreto +date: 2026-06-29 +deciders: rogerbarreto +consulted: [] +informed: [] +--- + +# Hosted platform context (user id + call id) for Foundry Hosting on AgentServer 2.0 + +Supersedes [ADR-0026](0026-hosted-session-identity-context.md). + +## Context and Problem Statement + +[ADR-0026](0026-hosted-session-identity-context.md) sourced the hosted-agent end-user identity from `ResponseContext.Isolation` (an `IsolationContext` typed `UserIsolationKey` / `ChatIsolationKey`), injected by the platform as the `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers. + +`Azure.AI.AgentServer.*` 2.0.0 (responses protocol `2.0.0`) removes that surface. `ResponseContext.Isolation` is gone; the platform now exposes `ResponseContext.PlatformContext` (a `PlatformContext` typed `UserIdKey` and `CallId`), populated from the `x-agent-user-id` and `x-agent-foundry-call-id` headers. The chat isolation key no longer exists, and a new per-request **call id** is introduced that first-party Foundry services (the toolbox proxy in particular) require on outbound calls to resolve the server-side-stored caller context. The hosting layer in `Microsoft.Agents.AI.Foundry.Hosting` had to migrate to this contract without changing the public shape that samples and providers depend on. + +## Decision Drivers + +- Track the breaking `Azure.AI.AgentServer.*` 2.0.0 surface (`PlatformContext` replacing `Isolation`) while keeping the same per-user partitioning guarantees from ADR-0026. +- Keep the change **internal**: existing hosted samples and `AIContextProvider`s must not need code changes. `session.GetHostedContext().UserId`, `HostedSessionIsolationKeyProvider`, and `AddFoundryResponses` stay source-compatible. +- Forward the new per-request call id verbatim on outbound calls to Foundry first-party services so per-user toolbox OAuth consent and other server-side caller-context lookups keep working. +- Remain resilient on protocol `1.0.0`: when only the legacy headers are present, `UserIdKey` still resolves and `CallId` is simply absent. +- Preserve the strict-resume tamper defense from ADR-0026 with identity now reduced to user only. + +## Considered Options + +For the identity source: + +1. **Map `ResponseContext.PlatformContext.UserIdKey`** into the existing `HostedSessionContext` (user only), keeping ADR-0026's storage shape and read accessor. +2. Keep a `ChatId` slot on `HostedSessionContext` for backward source-compatibility, populated from `CallId` or left null. + +For the call id propagation: + +A. **A request-scoped ambient (`HostedCallContext`, an `AsyncLocal`)** set by the handler and re-applied before each egress point, read by the outbound delegating handler. +B. Thread the call id through every method signature down to the toolbox bearer handler. + +For session keying (previously implied by the conversation/chat pairing): + +I. **`HostedConversationKey`** resolving a stable partition from `conversation_id ?? partition(previous_response_id) ?? partition(responseId)`. +II. Continue keying on the container session id (`FOUNDRY_AGENT_SESSION_ID`). + +## Decision Outcome + +Chosen: **Option 1** for identity, **Option A** for call id, **Option I** for session keying. + +Rationale: + +- **`ChatId` dropped (Option 2 rejected).** The platform no longer supplies a chat key; carrying a synthetic one would invent identity the trust boundary does not provide. `HostedSessionContext` becomes user-only (`HostedSessionContext(string userId)` / `UserId`), and the strict-resume check validates `UserId` alone. The corresponding `HostedFoundryMemoryProviderScopes` values `PerChat` and `PerUserAndChat` are removed; `PerUser` is retained. +- **Ambient call id (Option B rejected).** Writing `HostedCallContext.CallId` inside the streaming `async IAsyncEnumerable` iterator is reverted across each `yield`, so a single up-front assignment is lost before the toolbox/MCP egress runs. The handler therefore captures `context.PlatformContext?.CallId` once and **re-applies it immediately before each egress point**; `FoundryToolboxBearerTokenHandler` forwards it as `x-agent-foundry-call-id`. The ambient is request-scoped and never leaks into the caller's execution context (guarded by a unit test). +- **`HostedConversationKey` (Option II rejected).** One container serves many conversations for its lifetime, so the container session id cannot key per-conversation state. The partition key is derived from the conversation/`previous_response_id`/minted response id instead. + +Implementation summary in `Microsoft.Agents.AI.Foundry.Hosting`: + +| Type | Visibility | Change vs ADR-0026 | +|---|---|---| +| `HostedSessionContext` | public sealed | Now user-only (`UserId`); `ChatId` removed. | +| `PlatformHostedSessionIsolationKeyProvider` | internal sealed | Maps `context.PlatformContext.UserIdKey` (was `context.Isolation.UserIsolationKey` / `ChatIsolationKey`). | +| `HostedCallContext` | internal static | New. Request-scoped `AsyncLocal` holding the `x-agent-foundry-call-id` value. | +| `HostedConversationKey` | internal | New. Resolves the per-conversation partition key. | +| `FoundryToolboxBearerTokenHandler` | internal | Now also forwards `x-agent-foundry-call-id` outbound. | +| `HostedFoundryMemoryProviderScopes` | public | `PerChat` / `PerUserAndChat` removed; `PerUser` kept. | + +Package manifests bump the responses container protocol to `2.0.0` (invocations stays `1.0.0`). + +## Consequences + +Positive: + +- Per-user memory partitioning and the strict-resume tamper defense from ADR-0026 are preserved with no public API churn for samples or providers. +- Per-user toolbox OAuth consent and other server-side caller-context lookups keep working because the per-request call id is forwarded on egress. +- Works unchanged on protocol `1.0.0` (no call id) and `2.0.0`. + +Negative: + +- `HostedSessionContext.ChatId` and the `PerChat` / `PerUserAndChat` memory scopes are removed; any out-of-tree consumer that referenced them must move to user-scoped partitioning. +- The call id must be re-applied before every egress point because of the async-iterator `AsyncLocal` revert; a missed re-apply silently drops the header. This is covered by unit tests. + +## Out of scope + +- HMAC tamper signatures over the persisted context remain unimplemented; equality comparison against `ResponseContext.PlatformContext` on every request is sufficient because the platform sets the header at the trust boundary. +- The per-request `User` field on `CreateResponse` is still intentionally not consumed. diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 3d30f24e6ee..6d8657f0b90 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -23,9 +23,9 @@ - - - + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example index c40c94eb4a9..55f6e58e764 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example @@ -1,4 +1,4 @@ -FOUNDRY_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development FOUNDRY_MODEL=gpt-4o @@ -8,7 +8,6 @@ SKILL_NAMES=support-style,escalation-policy # In production, skills are provisioned externally — leave this unset or false. PROVISION_SAMPLE_SKILLS=true AZURE_BEARER_TOKEN=DefaultAzureCredential -# When running outside the Foundry platform the platform-injected isolation keys are absent. -# These two variables provide fallback values for local Docker debugging only. -HOSTED_USER_ISOLATION_KEY=local-dev-user -HOSTED_CHAT_ISOLATION_KEY=local-dev-chat +# When running outside the Foundry platform the platform-injected user-identity key is absent. +# This variable provides a fallback value for local Docker debugging only. +HOSTED_USER_ISOLATION_KEY=local-dev-user \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile.contributor index 8e3f8cdc225..b988d322ae6 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile.contributor +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Dockerfile.contributor @@ -1,4 +1,4 @@ -# Dockerfile for contributors building from the agent-framework repository source. +# Dockerfile for contributors building from the agent-framework repository source. # # This project uses ProjectReference to the local Microsoft.Agents.AI source, # which means a standard multi-stage Docker build cannot resolve dependencies outside @@ -10,7 +10,6 @@ # docker run --rm -p 8088:8088 \ # -e AGENT_NAME=hosted-agent-skills \ # -e HOSTED_USER_ISOLATION_KEY=alice \ -# -e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \ # --env-file .env hosted-agent-skills # # For end-users consuming the NuGet package (not ProjectReference), use the standard @@ -20,4 +19,4 @@ WORKDIR /app COPY out/ . EXPOSE 8088 ENV ASPNETCORE_URLS=http://+:8088 -ENTRYPOINT ["dotnet", "HostedAgentSkills.dll"] +ENTRYPOINT ["dotnet", "HostedAgentSkills.dll"] \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml index 83f442a6371..6c8ac5f4eaa 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-agent-skills displayName: "Hosted Agent Skills" @@ -21,7 +21,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml index a63017f828b..f544c2fbead 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-agent-skills protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1 b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1 index e13c94cfd6e..83652ea892c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1 +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1 @@ -1,4 +1,4 @@ -#requires -Version 7 +#requires -Version 7 <# .SYNOPSIS Local smoke test for the Hosted-AgentSkills sample. @@ -51,7 +51,6 @@ function Start-Container { -e AGENT_NAME=hosted-agent-skills ` -e AZURE_BEARER_TOKEN=$bearer ` -e HOSTED_USER_ISOLATION_KEY=smoke-user ` - -e HOSTED_CHAT_ISOLATION_KEY=smoke-chat-1 ` --env-file .env ` $ImageName | Out-Host if ($LASTEXITCODE -ne 0) { throw "docker run failed." } @@ -97,4 +96,4 @@ try { } finally { docker rm -f $ContainerName 2>$null | Out-Null -} +} \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.manifest.yaml index 453e8e9c7a8..3c263dc84ef 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-azure-search-rag displayName: "Hosted Azure AI Search RAG Agent" @@ -22,7 +22,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.yaml index 8ad6cf5bbd8..1c0c1cdb33c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-azure-search-rag protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml index 58a07d8bb3c..bc699d3b4b0 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-chat-client-agent displayName: "Hosted Chat Client Agent" @@ -19,7 +19,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml index 0a97abc35ac..0f97e93fed7 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-chat-client-agent protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi 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 index cda1ba64941..cccf64b828b 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# 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" @@ -21,7 +21,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml index f949ac09ee0..96dccbcf543 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# 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 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml index 9b33646c8a3..c7e46b4d786 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-foundry-agent displayName: "Hosted Foundry Agent" @@ -19,7 +19,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.yaml index 74223e72fe3..732a4a7e2e1 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-foundry-agent protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml index f00aedf60e3..21ffda6c9e2 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-local-codeact displayName: "Hosted Local CodeAct Agent" @@ -21,7 +21,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.5" memory: 1Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.yaml index cdf4b5c9a13..1076d72c391 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-local-codeact protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.5" memory: 1Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml index a056b51649f..935d509054c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-local-tools displayName: "Seattle Hotel Agent with Local Tools" @@ -20,7 +20,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.yaml index 18ecc4a9f7d..ee253cfb67b 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-local-tools protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml index d5952940b0b..546a7452ee4 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: mcp-tools displayName: "MCP Tools Agent" @@ -21,7 +21,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.yaml index 34beb3e2c9f..fcc2015d081 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: mcp-tools protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/.env.example index 9eb77d2a5ba..48d587be59e 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/.env.example @@ -1,4 +1,4 @@ -FOUNDRY_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development FOUNDRY_MODEL=gpt-4o @@ -6,7 +6,6 @@ AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002 AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample AGENT_NAME=hosted-memory-agent AZURE_BEARER_TOKEN=DefaultAzureCredential -# When running outside the Foundry platform the platform-injected isolation keys are absent. -# These two variables provide fallback values for local Docker debugging only. -HOSTED_USER_ISOLATION_KEY=local-dev-user -HOSTED_CHAT_ISOLATION_KEY=local-dev-chat +# When running outside the Foundry platform the platform-injected user-identity key is absent. +# This variable provides a fallback value for local Docker debugging only. +HOSTED_USER_ISOLATION_KEY=local-dev-user \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Dockerfile.contributor index 71df8d599e0..e23a38022ae 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Dockerfile.contributor +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Dockerfile.contributor @@ -1,4 +1,4 @@ -# Dockerfile for contributors building from the agent-framework repository source. +# 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 @@ -10,7 +10,6 @@ # docker run --rm -p 8088:8088 \ # -e AGENT_NAME=hosted-memory-agent \ # -e HOSTED_USER_ISOLATION_KEY=alice \ -# -e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \ # --env-file .env hosted-memory-agent # # For end-users consuming the NuGet package (not ProjectReference), use the standard @@ -20,4 +19,4 @@ WORKDIR /app COPY out/ . EXPOSE 8088 ENV ASPNETCORE_URLS=http://+:8088 -ENTRYPOINT ["dotnet", "HostedMemoryAgent.dll"] +ENTRYPOINT ["dotnet", "HostedMemoryAgent.dll"] \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs index 1221dcbc9d5..416d613d267 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs @@ -7,8 +7,8 @@ // 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 +// 1. Foundry sets x-agent-user-id on every inbound request. +// 2. AgentFrameworkResponseHandler reads context.PlatformContext.UserIdKey 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. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md index ea3bf51c5bd..fd968c7dd6a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md @@ -1,7 +1,7 @@ # 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 +requests and across sessions, scoped per end user via the Foundry platform's user identity. 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. @@ -9,10 +9,9 @@ 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. +2. How the `HostedSessionContext` flows from the Foundry platform user-identity header + (`x-agent-user-id`) through the `HostedSessionIsolationKeyProvider` into the provider's + `stateInitializer`, so memories are partitioned per user automatically. ## Prerequisites @@ -44,7 +43,6 @@ 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. @@ -53,18 +51,18 @@ HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 | 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). | +| Inbound request | The Foundry platform sets the `x-agent-user-id` header on every request. | +| Hosting layer | `AgentFrameworkResponseHandler` resolves a `HostedSessionIsolationKeyProvider` from DI and calls `GetKeysAsync(context, request, ct)`. The default implementation reads `context.PlatformContext.UserIdKey`. | +| Session | The handler stores the resolved value on the session as a `HostedSessionContext` on the first request, and validates it 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 +When running outside the Foundry platform the header is 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. +falls back to the `HOSTED_USER_ISOLATION_KEY` environment variable, +defaulting to a single `local-dev-*` bucket when it is not set. > **Production warning.** Never register `DevTemporaryLocalSessionIsolationKeyProvider` in -> production. The Foundry platform sets the isolation keys for every inbound request, and +> production. The Foundry platform sets the user-identity key for every inbound request, and > client-supplied environment variables can be forged. ## Running directly (contributors) @@ -121,7 +119,6 @@ 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 ``` @@ -180,4 +177,4 @@ standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented sec | **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 | +| **Local dev** | `AddDevTemporaryLocalContributorSetup()` keeps requests succeeding when isolation headers are absent | Same; additionally honours `HOSTED_USER_ISOLATION_KEY` to simulate distinct users | \ No newline at end of file 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 index 8ff9e566c51..8c733262d1c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# 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" @@ -22,7 +22,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.yaml index f7b65589a42..8b5e602ef7e 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# 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 + version: 2.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 index fd357518396..eac27c009ad 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/scripts/smoke.ps1 +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/scripts/smoke.ps1 @@ -1,4 +1,4 @@ -#requires -Version 7 +#requires -Version 7 <# .SYNOPSIS Local smoke test for the Hosted-MemoryAgent sample. @@ -44,13 +44,12 @@ 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) { +function Start-Container([string]$UserKey, [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." } @@ -82,7 +81,7 @@ function Assert-NotContains([string]$Haystack, [string]$Needle, [string]$Label) 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' + Start-Container -UserKey 'alice' -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 @@ -96,7 +95,7 @@ try { 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' + Start-Container -UserKey 'bob' -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' @@ -107,4 +106,4 @@ try { finally { docker rm -f hosted-memory-smoke-alice 2>$null | Out-Null docker rm -f hosted-memory-smoke-bob 2>$null | Out-Null -} +} \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.manifest.yaml index 92f51d1a905..87b6aa55f65 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-observability displayName: "Hosted Observability Agent" @@ -21,7 +21,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.yaml index 93146bdc5dc..6fd75029b46 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-observability protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml index 1459925136e..4e8330a025e 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-text-rag displayName: "Hosted Text RAG Agent" @@ -21,7 +21,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.yaml index c8d6928e2e4..a73a702d9cb 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-text-rag protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.manifest.yaml index 77336ea78f1..38a2cbaab5a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-toolbox-auth-paths displayName: "Hosted Toolbox - Authentication Paths" @@ -24,7 +24,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.yaml index 87cca57bb79..e5340036892 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-toolbox-auth-paths protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/agent.manifest.yaml index 40bda33f185..7542ef8bcd2 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-toolbox displayName: "Hosted Toolbox" @@ -23,7 +23,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/agent.yaml index 3a743b81e5e..60ac73e6d8a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-toolbox protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml index ed3d6a9eb0c..1ef15df3be1 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-toolbox-mcp-skills displayName: "Hosted Toolbox MCP Skills Agent" @@ -23,7 +23,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml index ac766bff9c3..b3760fdc696 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-toolbox-mcp-skills protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj index 453de785f7b..2d913f0842f 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj @@ -13,7 +13,10 @@ - + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml index 7909463901a..54ab18dd91a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: triage-workflow displayName: "Triage Handoff Workflow Agent" @@ -21,7 +21,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml index 6b192c4eb6a..6de1c5107f9 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: triage-workflow protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml index e902b6232fe..9b1d3d10ab2 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: hosted-workflows displayName: "Translation Chain Workflow Agent" @@ -20,7 +20,7 @@ template: kind: hosted protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.yaml index c9c0386cf1e..fad8a33470c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.yaml @@ -1,9 +1,9 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted name: hosted-workflow-simple protocols: - protocol: responses - version: 1.0.0 + version: 2.0.0 resources: cpu: "0.25" memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/DevTemporaryLocalSessionIsolationKeyProvider.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/DevTemporaryLocalSessionIsolationKeyProvider.cs index 6f94f01d2c5..d9d74779478 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/DevTemporaryLocalSessionIsolationKeyProvider.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/DevTemporaryLocalSessionIsolationKeyProvider.cs @@ -9,17 +9,16 @@ namespace Hosted_Shared_Contributor_Setup; /// /// A for local Docker debugging only. /// -/// When the Foundry platform's x-agent-user-isolation-key and -/// x-agent-chat-isolation-key headers are absent (i.e., when the container is running -/// outside the Foundry platform), the hosting layer rejects every request with a 500 because the -/// default returns null. This provider supplies -/// fallback values from the HOSTED_USER_ISOLATION_KEY and HOSTED_CHAT_ISOLATION_KEY -/// environment variables, defaulting to the constants below when neither is set. +/// When the Foundry platform's x-agent-user-id header is absent (i.e., when the container is +/// running outside the Foundry platform), the hosting layer rejects every request with a 500 because +/// the default returns null. This provider supplies a +/// fallback value from the HOSTED_USER_ISOLATION_KEY environment variable, defaulting to the +/// constant below when it is not set. /// -/// This should NOT be used in production. The Foundry platform sets the isolation keys for every -/// inbound request and forging them client-side defeats the per-user partitioning. The dev -/// fallback exists solely so a contributor can docker run the sample on their laptop and -/// drive a few requests end to end. +/// This should NOT be used in production. The Foundry platform sets the user id for every inbound +/// request and forging it client-side defeats the per-user partitioning. The dev fallback exists +/// solely so a contributor can docker run the sample on their laptop and drive a few requests +/// end to end. /// public sealed class DevTemporaryLocalSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider { @@ -28,45 +27,26 @@ public sealed class DevTemporaryLocalSessionIsolationKeyProvider : HostedSession /// public const string UserIsolationKeyEnvironmentVariable = "HOSTED_USER_ISOLATION_KEY"; - /// - /// Environment variable that supplies the chat isolation key when the platform header is absent. - /// - public const string ChatIsolationKeyEnvironmentVariable = "HOSTED_CHAT_ISOLATION_KEY"; - /// /// Default user isolation key used when neither the platform header nor the environment variable /// supplies a value. All local requests collapse onto this single bucket unless overridden. /// public const string DefaultLocalUserIsolationKey = "local-dev-user"; - /// - /// Default chat isolation key used when neither the platform header nor the environment variable - /// supplies a value. - /// - public const string DefaultLocalChatIsolationKey = "local-dev-chat"; - /// public override ValueTask GetKeysAsync( ResponseContext context, CreateResponse request, CancellationToken cancellationToken) { - var userKey = !string.IsNullOrWhiteSpace(context?.Isolation?.UserIsolationKey) - ? context!.Isolation!.UserIsolationKey + var userKey = !string.IsNullOrWhiteSpace(context?.PlatformContext?.UserIdKey) + ? context!.PlatformContext!.UserIdKey : Environment.GetEnvironmentVariable(UserIsolationKeyEnvironmentVariable); if (string.IsNullOrWhiteSpace(userKey)) { userKey = DefaultLocalUserIsolationKey; } - var chatKey = !string.IsNullOrWhiteSpace(context?.Isolation?.ChatIsolationKey) - ? context!.Isolation!.ChatIsolationKey - : Environment.GetEnvironmentVariable(ChatIsolationKeyEnvironmentVariable); - if (string.IsNullOrWhiteSpace(chatKey)) - { - chatKey = DefaultLocalChatIsolationKey; - } - - return new ValueTask(new HostedSessionContext(userKey!, chatKey!)); + return new ValueTask(new HostedSessionContext(userKey!)); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 97b6ec8af31..2e853bfd48b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -63,8 +63,13 @@ public override async IAsyncEnumerable CreateAsync( var agent = this.ResolveAgent(request); var sessionStore = this.ResolveSessionStore(request); - // 2. Load or create a new session from the interaction - var sessionConversationId = request.GetConversationId(); + // 2. Load or create a new session from the interaction. + // Map the request to a stable MAF AgentSession key: conversation_id when present, else the + // partition embedded in previous_response_id (chains converge), else the minted response id + // (cold start). Container session id is intentionally not used — it spans many conversations. + var conversationId = request.GetConversationId(); + var sessionConversationId = HostedConversationKey.Resolve( + conversationId, request.PreviousResponseId, context.ResponseId); var chatClientAgent = agent.GetService(); @@ -84,10 +89,17 @@ public override async IAsyncEnumerable CreateAsync( { throw new InvalidOperationException( $"The registered {nameof(HostedSessionIsolationKeyProvider)} returned null for the current request. " + - "Ensure the Foundry platform is providing the x-agent-user-isolation-key and x-agent-chat-isolation-key headers, " + + "Ensure the Foundry platform is providing the x-agent-user-id header, " + "or register a custom provider that supplies fallback values for local development."); } + // Capture the platform per-request call id (x-agent-foundry-call-id, protocol 2.0.0 only). + // It is re-applied to the ambient HostedCallContext immediately before each outbound egress + // point below: AsyncLocal writes made in this streaming iterator are reverted across yield + // boundaries, so a single up-front assignment would be lost before the toolbox/MCP calls run. + var platformCallId = context.PlatformContext?.CallId; + HostedCallContext.CallId = platformCallId; + if (session is not null) { var existingHostedContext = session.GetHostedContext(); @@ -98,8 +110,7 @@ public override async IAsyncEnumerable CreateAsync( // prior hosted-agent request having stamped a context). Stamp it now. session.SetHostedContext(resolvedHostedContext); } - else if (!string.Equals(existingHostedContext.UserId, resolvedHostedContext.UserId, StringComparison.Ordinal) - || !string.Equals(existingHostedContext.ChatId, resolvedHostedContext.ChatId, StringComparison.Ordinal)) + else if (!string.Equals(existingHostedContext.UserId, resolvedHostedContext.UserId, StringComparison.Ordinal)) { // Resume path: the persisted identity must match the live request. A mismatch // signals either a cross-user session leak or in-process tampering of the @@ -124,7 +135,7 @@ public override async IAsyncEnumerable CreateAsync( // (e.g. resuming a workflow paused at an external-input port), the workflow's // checkpointed state already contains the prior turns' messages — replaying history // would re-drive completed actions and break HITL resume semantics. - var isResume = !string.IsNullOrWhiteSpace(sessionConversationId) + var isResume = (!string.IsNullOrWhiteSpace(conversationId) || !string.IsNullOrWhiteSpace(request.PreviousResponseId)) && session?.StateBag?.Count > 0; if (!isResume) { @@ -163,6 +174,10 @@ public override async IAsyncEnumerable CreateAsync( // in both the pre-registered list and the per-request markers. if (this._toolboxService is not null) { + // Re-apply the call id: the EmitCreated/EmitInProgress yields above reverted the ambient + // value, and the toolbox tools/list + consent egress below must carry it per request. + HostedCallContext.CallId = platformCallId; + // Retry any pre-registered toolbox that was deferred at startup because it could not be // enumerated without a per-user context (non-consent failure). The request's egress now // carries the platform-injected per-user isolation key, so a delegated tool source can @@ -312,6 +327,11 @@ await this._toolboxService { while (true) { + // Re-apply the call id before each pull from the agent stream: the per-event yields + // below revert the ambient AsyncLocal, but the MCP tools/call egress that happens + // inside MoveNextAsync must carry the platform call id on every request. + HostedCallContext.CallId = platformCallId; + bool shutdownDetected = false; McpConsentInfo? consentInfo = null; ResponseStreamEvent? failedEvent = null; diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs index 23e28583229..7c1d194cbe1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs @@ -54,6 +54,14 @@ protected override async Task SendAsync( request.Headers.TryAddWithoutValidation("Foundry-Features", BuildFeaturesHeaderValue(this._additionalFeaturesHeaderValue)); + // Per PlatformContext, forward the platform per-request call id (x-agent-foundry-call-id, + // container protocol 2.0.0) so the toolbox proxy can resolve the server-side caller context. + var callId = HostedCallContext.CallId; + if (!string.IsNullOrWhiteSpace(callId) && !request.Headers.Contains("x-agent-foundry-call-id")) + { + request.Headers.TryAddWithoutValidation("x-agent-foundry-call-id", callId); + } + PropagateTraceContext(request); // MaxRetries is the total number of attempts (not additional retries after the first). diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedCallContext.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedCallContext.cs new file mode 100644 index 00000000000..52be548ffab --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedCallContext.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Ambient holder for the platform-injected per-request call identifier +/// (x-agent-foundry-call-id, available only on container protocol version 2.0.0). +/// +/// +/// The call id is opaque and per-request, so it is not stored on the session. The hosting layer +/// sets it for the duration of a request and outbound delegating handlers (e.g. the toolbox bearer +/// handler) forward it verbatim on calls to Foundry first-party services so those services can +/// resolve the server-side-stored caller context. +/// +internal static class HostedCallContext +{ + private static readonly AsyncLocal s_callId = new(); + + /// Gets or sets the current request's call id, or when absent. + public static string? CallId + { + get => s_callId.Value; + set => s_callId.Value = value; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedConversationKey.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedConversationKey.cs new file mode 100644 index 00000000000..8216038dd20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedConversationKey.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Derives the stable per-conversation key used to map a hosted request to a persisted MAF +/// . The key mirrors how the AgentServer Responses SDK colocates +/// state: it prefers the request's conversation_id, falls back to the partition key +/// embedded in previous_response_id, and finally to the partition of the freshly minted +/// response id (cold start). All response ids in a chain share the same partition, so a +/// conversation continued purely via previous_response_id (no stored conversation) still +/// converges to a single MAF session. +/// +/// +/// This is deliberately conversation-level: it must NOT use the container session id +/// (FOUNDRY_AGENT_SESSION_ID / x-agent-session-id), which is constant for the whole +/// container and serves many conversations. The id format mirrors the SDK's internal +/// IdGenerator: {prefix}_{partition}{entropy} with a 50-char body (18-char +/// partition) for the current format and a 48-char legacy body (16-char trailing partition). +/// +internal static class HostedConversationKey +{ + private const int NewFormatBodyLength = 50; + private const int NewFormatPartitionLength = 18; + private const int LegacyBodyLength = 48; + private const int LegacyPartitionLength = 16; + + /// + /// Resolves the conversation key from conversation id, previous response id, and the minted + /// response id, in that order. Returns when none is available. + /// + public static string? Resolve(string? conversationId, string? previousResponseId, string? responseId) + { + if (!string.IsNullOrWhiteSpace(conversationId)) + { + return conversationId; + } + + return PartitionOf(previousResponseId) ?? PartitionOf(responseId); + } + + /// + /// Extracts the stable partition key from a response/item id. Returns + /// when the id is empty. Ids that don't match the known body lengths fall back to the raw value. + /// + public static string? PartitionOf(string? id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + + int delimiter = id!.IndexOf('_'); + string body = delimiter < 0 ? id : id.Substring(delimiter + 1); + + return body.Length switch + { + NewFormatBodyLength => body.Substring(0, NewFormatPartitionLength), + LegacyBodyLength => body.Substring(body.Length - LegacyPartitionLength), + _ => id, + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedFoundryMemoryProviderScopes.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedFoundryMemoryProviderScopes.cs index 1dd8ee5f45d..93bea6cd389 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedFoundryMemoryProviderScopes.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedFoundryMemoryProviderScopes.cs @@ -34,43 +34,6 @@ public static class HostedFoundryMemoryProviderScopes public static Func PerUser() => session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).UserId)); - /// - /// Returns a stateInitializer that scopes memories per conversation, using - /// as the partition key. Use this when memories should - /// be visible to every participant in a shared conversation (for example, a Teams group chat). - /// - /// A delegate suitable for the stateInitializer argument of . - public static Func PerChat() => - session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId)); - - /// - /// Returns a stateInitializer that scopes memories per (user, chat) pair, composing - /// and into a - /// single delimiter-safe partition key. Use this when memories should be visible only to the same - /// user within the same conversation. - /// - /// - /// Both identity values are opaque strings that may contain any characters, including the : - /// delimiter. To keep the composite key injective (so two distinct (user, chat) pairs can never - /// collide), each part is escaped (\ becomes \\, then : becomes \:) before - /// being joined with a :: separator. - /// - /// A delegate suitable for the stateInitializer argument of . - public static Func PerUserAndChat() => - session => - { - var ctx = GetRequiredHostedContext(session); - return new FoundryMemoryProvider.State( - new FoundryMemoryProviderScope($"{EscapeScopePart(ctx.UserId)}::{EscapeScopePart(ctx.ChatId)}")); - }; - - /// - /// Escapes special characters in a scope part so that distinct (user, chat) pairs produce distinct - /// composite scope keys. Backslashes are escaped first (\ becomes \\), then colons - /// (: becomes \:), ensuring the {user}::{chat} format is unambiguous. - /// - private static string EscapeScopePart(string part) => part.Replace("\\", "\\\\").Replace(":", "\\:"); - private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) => session?.GetHostedContext() ?? throw new InvalidOperationException( diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionContext.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionContext.cs index 096b61a1953..7656faf9533 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionContext.cs @@ -13,17 +13,20 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// /// The partitions data that belongs to the individual who initiated the request -/// (e.g., personal memory, per-user preferences). The partitions data that belongs -/// to the conversation (e.g., conversation history, turn state). Both values are opaque strings whose -/// meaning is determined by the active . +/// (e.g., personal memory, per-user preferences). It is an opaque string whose meaning is determined +/// by the active . /// /// /// Instances are constructed by the hosting layer from the platform-provided -/// IsolationContext headers and stored on the session via +/// PlatformContext and stored on the session via /// . Consumers (typically -/// implementations) read the values through +/// implementations) read the value through /// . /// +/// +/// On container protocol version 2.0.0 there is no per-chat partition header; chat-level +/// isolation is handled by the platform, so the container partitions per user only. +/// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class HostedSessionContext @@ -32,12 +35,10 @@ public sealed class HostedSessionContext /// Initializes a new instance of the class. /// /// The opaque user identity for this hosted session. Must not be null or whitespace. - /// The opaque chat (conversation) identity for this hosted session. Must not be null or whitespace. - /// Thrown when or is null or whitespace. - public HostedSessionContext(string userId, string chatId) + /// Thrown when is null or whitespace. + public HostedSessionContext(string userId) { this.UserId = Throw.IfNullOrWhitespace(userId); - this.ChatId = Throw.IfNullOrWhitespace(chatId); } /// @@ -45,17 +46,7 @@ public HostedSessionContext(string userId, string chatId) /// /// /// Stable for a given user across sessions. In production this is sourced from the - /// x-agent-user-isolation-key platform header. + /// x-agent-user-id platform header. /// public string UserId { get; } - - /// - /// Gets the opaque chat (conversation) identity for this hosted session. - /// - /// - /// In a 1:1 user-to-agent chat this typically equals . In shared-surface - /// scenarios (e.g., a Teams group chat) it represents the common partition all participants - /// write to. In production this is sourced from the x-agent-chat-isolation-key platform header. - /// - public string ChatId { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionIsolationKeyProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionIsolationKeyProvider.cs index 7eabfd3ad68..c1a37aa2b91 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionIsolationKeyProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedSessionIsolationKeyProvider.cs @@ -20,16 +20,15 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// /// The default implementation registered when no custom -/// is present in DI maps the platform-injected x-agent-user-isolation-key and -/// x-agent-chat-isolation-key headers via . Hosting samples and contributor-only environments +/// is present in DI maps the platform-injected x-agent-user-id header via +/// . Hosting samples and contributor-only environments /// can register an alternate implementation in DI to provide values when the platform headers are absent /// (e.g., during local Docker debugging). /// /// /// Implementations must return a whose -/// and are both non-null and non-whitespace. Returning either as null -/// (or throwing from ) is treated as a configuration error and surfaces as a -/// 500 from the hosting layer. +/// is non-null and non-whitespace. Returning null (or throwing from ) is treated +/// as a configuration error and surfaces as a 500 from the hosting layer. /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] @@ -42,8 +41,8 @@ public abstract class HostedSessionIsolationKeyProvider /// The describing the incoming request. /// The to monitor for cancellation requests. /// - /// A with non-null and - /// , or when the implementation cannot + /// A with non-null , + /// or when the implementation cannot /// produce identity keys for the current request. A result is treated as a /// configuration error by the hosting layer and surfaces as 500. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj index ec348b2167c..3ff66161f0d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj @@ -5,7 +5,8 @@ Microsoft.Agents.AI.Foundry.Hosting preview Microsoft Agent Framework for Foundry Hosted Agents - Provides Microsoft Agent Framework support for hosting Foundry Agents with the Azure AI Agent Service. + Provides Microsoft Agent Framework support for hosting Foundry Agents with the Azure AI Agent Service. Breaking change: this version targets the Foundry Responses container protocol v2.0 only and is not compatible with v1; use an earlier release for the v1 protocol definition. + Breaking change: this release migrates Foundry hosting to the Azure.AI.AgentServer 2.0 (Responses container protocol v2.0) surface. It is only compatible with responses protocol v2.0. Use a previous release for the v1 protocol definition. See docs/decisions/0030-hosted-platform-context-agentserver-2.0.md. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/PlatformHostedSessionIsolationKeyProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/PlatformHostedSessionIsolationKeyProvider.cs index a72759a11aa..1c96cc7e9ca 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/PlatformHostedSessionIsolationKeyProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/PlatformHostedSessionIsolationKeyProvider.cs @@ -11,16 +11,16 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// Default implementation that maps the platform-injected -/// x-agent-user-isolation-key and x-agent-chat-isolation-key headers from -/// into a . +/// x-agent-user-id header from into a +/// . /// /// /// This is the implementation used in production Foundry hosted environments. When running locally -/// outside the platform, both isolation keys are , which causes +/// outside the platform, the user id is , which causes /// to return . The hosting layer treats a null /// result as a configuration error and surfaces it as a 500 from the request. Local development /// should register an alternate implementation -/// that provides fallback values for the missing headers. +/// that provides a fallback value for the missing header. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] internal sealed class PlatformHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider @@ -31,14 +31,13 @@ internal sealed class PlatformHostedSessionIsolationKeyProvider : HostedSessionI CreateResponse request, CancellationToken cancellationToken) { - var userKey = context?.Isolation?.UserIsolationKey; - var chatKey = context?.Isolation?.ChatIsolationKey; + var userKey = context?.PlatformContext?.UserIdKey; - if (string.IsNullOrWhiteSpace(userKey) || string.IsNullOrWhiteSpace(chatKey)) + if (string.IsNullOrWhiteSpace(userKey)) { return new ValueTask((HostedSessionContext?)null); } - return new ValueTask(new HostedSessionContext(userKey!, chatKey!)); + return new ValueTask(new HostedSessionContext(userKey!)); } } diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs index 29d718dda78..5b5f6a9ec40 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs @@ -31,6 +31,7 @@ AIAgent agent = scenario switch { "happy-path" => CreateHappyPathAgent(projectClient, deployment), + "store-config" => CreateStoreConfigAgent(projectClient, deployment), "tool-calling" => CreateToolCallingAgent(projectClient, deployment), "tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment), "mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment), @@ -70,10 +71,22 @@ static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) => client.AsAIAgent( model: deployment, - instructions: "You are a helpful AI assistant. Always reply with exactly the single word ECHO unless the user explicitly asks a question that requires a different answer.", + instructions: "You are a helpful assistant. Answer the user's question concisely and accurately. " + + "At the very end of every reply, append the marker token CONTAINER-OK on its own line.", name: "happy-path-agent", description: "Round trip and conversation test agent."); +// store-config scenario: a neutral assistant used to exercise store/session semantics +// (store=true/false, previous_response_id and conversation_id forks, multi-turn recall). It has no +// marker instruction so it never contaminates the content/recall assertions. +static AIAgent CreateStoreConfigAgent(AIProjectClient client, string deployment) => + client.AsAIAgent( + model: deployment, + instructions: "You are a helpful assistant. Answer the user's question concisely and accurately, " + + "and use any facts the user told you earlier in the conversation.", + name: "store-config-agent", + description: "Store and session semantics test agent."); + static AIAgent CreateToolCallingAgent(AIProjectClient client, string deployment) => client.AsAIAgent( model: deployment, diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs index 3b862aa26d7..f16c5e32de1 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedAgentFixture.cs @@ -41,6 +41,7 @@ public abstract class HostedAgentFixture : IAsyncLifetime { private const string ScenarioEnvironmentVariable = "IT_SCENARIO"; private const string RunIdEnvironmentVariable = "IT_RUN_ID"; + private const string ModelDeploymentEnvironmentVariable = "AZURE_AI_MODEL_DEPLOYMENT_NAME"; private const string FoundryFeaturesHeader = "Foundry-Features"; private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview"; private const string EnableVnextExperienceMetadataKey = "enableVnextExperience"; @@ -90,13 +91,21 @@ public abstract class HostedAgentFixture : IAsyncLifetime /// public AIProjectClient ProjectClient { get; private set; } = null!; + /// + /// The per-agent bound to this scenario's hosted agent endpoint + /// (/agents/{name}/endpoint/protocols/openai). Stored hosted-agent responses are only + /// readable through this per-agent client; the project-level responses client returns + /// session_not_accessible (403). Use this to fetch a response by id. + /// + public ProjectOpenAIClient AgentOpenAIClient { get; private set; } = null!; + /// /// Creates a server side conversation that tests can pass via ChatOptions.ConversationId /// to exercise multi turn flows backed by the Foundry conversations service. /// public async Task CreateConversationAsync() { - var response = await this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().CreateProjectConversationAsync().ConfigureAwait(false); + var response = await this.AgentOpenAIClient.GetProjectConversationsClient().CreateProjectConversationAsync().ConfigureAwait(false); return response.Value.Id; } @@ -107,7 +116,7 @@ public async Task DeleteConversationAsync(string conversationId) { try { - await this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(conversationId).ConfigureAwait(false); + await this.AgentOpenAIClient.GetProjectConversationsClient().DeleteConversationAsync(conversationId).ConfigureAwait(false); } catch { @@ -122,7 +131,7 @@ public async Task DeleteConversationAsync(string conversationId) public async Task CountConversationItemsAsync(string conversationId) { var count = 0; - await foreach (var _ in this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc").ConfigureAwait(false)) + await foreach (var _ in this.AgentOpenAIClient.GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc").ConfigureAwait(false)) { count++; } @@ -148,8 +157,16 @@ public async ValueTask InitializeAsync() { Image = image, }; - definition.Versions.Add(new ProtocolVersionRecord(ProjectsAgentProtocol.Responses, "1.0.0")); + definition.Versions.Add(new ProtocolVersionRecord(ProjectsAgentProtocol.Responses, "2.0.0")); definition.EnvironmentVariables[ScenarioEnvironmentVariable] = this.ScenarioName; + // Forward the test-side model deployment to the container so it targets the same model the + // tests expect. Without this the container falls back to its hard-coded default (gpt-4o), + // which fails on projects that only deploy a different model. + var modelDeployment = TestConfiguration.GetValue(TestSettings.AzureAIModelDeploymentName); + if (!string.IsNullOrWhiteSpace(modelDeployment)) + { + definition.EnvironmentVariables[ModelDeploymentEnvironmentVariable] = modelDeployment; + } // Foundry deduplicates versions by content hash, so a fixture re-using the same // definition would just receive the bootstrap version and then delete it on dispose. // Adding a per-run env var forces a brand new version that the dispose can safely remove @@ -181,6 +198,7 @@ public async ValueTask InitializeAsync() var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this.AgentName }; openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall); var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions); + this.AgentOpenAIClient = openAIClient; var responsesClient = openAIClient.GetProjectResponsesClient(); this.Agent = responsesClient.AsIChatClient().AsAIAgent(name: this.AgentName); diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedResponsesStoreConfigFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedResponsesStoreConfigFixture.cs new file mode 100644 index 00000000000..dd19d73933d --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedResponsesStoreConfigFixture.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Foundry.Hosting.IntegrationTests.Fixtures; + +/// +/// Provisions a hosted agent that runs the test container in IT_SCENARIO=store-config mode. +/// Used by HostedResponsesStoreConfigTests to exercise store/session semantics: store=true +/// vs store=false, previous_response_id and conversation_id forks, and multi-turn +/// recall. The container agent is a neutral assistant with no marker instruction so it never +/// contaminates the content assertions. +/// +public sealed class HostedResponsesStoreConfigFixture : HostedAgentFixture +{ + protected override string ScenarioName => "store-config"; +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/HappyPathHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/HappyPathHostedAgentTests.cs index c5e4802241e..d289cc533f9 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/HappyPathHostedAgentTests.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/HappyPathHostedAgentTests.cs @@ -2,18 +2,13 @@ using System; using System.Threading.Tasks; -using Azure.AI.Projects; using Foundry.Hosting.IntegrationTests.Fixtures; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using OpenAI.Responses; - -#pragma warning disable OPENAI001 // Experimental Responses API surfaces namespace Foundry.Hosting.IntegrationTests; /// -/// Round trip and conversation oriented integration tests against a hosted Responses agent. +/// Basic round trip, streaming, and container-instruction behaviour for a hosted Responses agent. +/// Store and session semantics live in . /// [Trait("Category", "FoundryHostedAgents")] public sealed class HappyPathHostedAgentTests(HappyPathHostedAgentFixture fixture) : IClassFixture @@ -54,150 +49,11 @@ public async Task RunStreamingAsync_YieldsAtLeastOneUpdateAsync() Assert.False(string.IsNullOrWhiteSpace(string.Concat(collected))); } - [Fact] - public async Task MultiTurn_WithPreviousResponseId_PreservesContextAsync() - { - // Arrange - var agent = this._fixture.Agent; - var session = await agent.CreateSessionAsync(); - - // Act - var first = await agent.RunAsync("My favorite number is 42. Acknowledge briefly.", session); - Assert.False(string.IsNullOrWhiteSpace(first.Text)); - - var second = await agent.RunAsync("What number did I just tell you?", session); - - // Assert - Assert.Contains("42", second.Text); - } - - [Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")] - public async Task MultiTurn_WithConversationId_PreservesContextAsync() - { - // Arrange - var agent = this._fixture.Agent; - var conversationId = await this._fixture.CreateConversationAsync(); - try - { - var options = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId }); - - // Act - var first = await agent.RunAsync("My favorite color is teal. Acknowledge briefly.", options: options); - Assert.False(string.IsNullOrWhiteSpace(first.Text)); - - var second = await agent.RunAsync("What color did I just tell you?", options: options); - - // Assert - Assert.Contains("teal", second.Text, StringComparison.OrdinalIgnoreCase); - } - finally - { - await this._fixture.DeleteConversationAsync(conversationId); - } - } - - [Fact] - public async Task StoredFalse_Baseline_DoesNotPersistResponseAsync() - { - // Arrange - var agent = this._fixture.Agent; - var options = new ChatClientAgentRunOptions(new ChatOptions - { - RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false } - }); - - // Act - var response = await agent.RunAsync("Reply with the word 'pong'.", options: options); - - // Assert: response returned but the response id is not retrievable from the chain. - Assert.False(string.IsNullOrWhiteSpace(response.Text)); - var responseId = response.ResponseId; - Assert.False(string.IsNullOrWhiteSpace(responseId)); - - // Attempting to fetch the response should fail because nothing was stored. - var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient(); - await Assert.ThrowsAnyAsync(() => responsesClient.GetResponseAsync(responseId)); - } - - [Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")] - public async Task StoredFalse_WithPreviousResponseId_ReadsHistoryButDoesNotAppendAsync() - { - // Arrange - var agent = this._fixture.Agent; - var session = await agent.CreateSessionAsync(); - - // Turn 1 is stored so the chain head exists. - var first = await agent.RunAsync("Remember the number 73. Acknowledge briefly.", session); - - // Turn 2 is stored=false but reads from turn 1 via the same session. - var optionsNoStore = new ChatClientAgentRunOptions(new ChatOptions - { - RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false } - }); - - // Act - var second = await agent.RunAsync("What number did I just tell you?", session, optionsNoStore); - - // Assert: model received history (knows the number) but the new response is not persisted. - Assert.Contains("73", second.Text); - var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient(); - await Assert.ThrowsAnyAsync(() => responsesClient.GetResponseAsync(second.ResponseId!)); - } - - [Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")] - public async Task StoredFalse_WithConversationId_ReadsHistoryButDoesNotAppendAsync() - { - // Arrange - var agent = this._fixture.Agent; - var conversationId = await this._fixture.CreateConversationAsync(); - try - { - var stored = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId }); - var notStored = new ChatClientAgentRunOptions(new ChatOptions - { - ConversationId = conversationId, - RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false } - }); - - // Turn 1 stored, populates the conversation. - await agent.RunAsync("Remember the number 99. Acknowledge briefly.", options: stored); - var beforeCount = await this._fixture.CountConversationItemsAsync(conversationId); - - // Act: turn 2 reads from conversation but is not appended. - var second = await agent.RunAsync("What number did I just tell you?", options: notStored); - - // Assert - Assert.Contains("99", second.Text); - var afterCount = await this._fixture.CountConversationItemsAsync(conversationId); - Assert.Equal(beforeCount, afterCount); - } - finally - { - await this._fixture.DeleteConversationAsync(conversationId); - } - } - - [Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")] - public async Task StoredTrue_Default_PersistsResponseInChainAsync() - { - // Arrange - var agent = this._fixture.Agent; - - // Act - var response = await agent.RunAsync("Reply with the word 'ack'."); - - // Assert - Assert.False(string.IsNullOrWhiteSpace(response.Text)); - var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient(); - var fetched = await responsesClient.GetResponseAsync(response.ResponseId!); - Assert.NotNull(fetched.Value); - } - [Fact] public async Task Instructions_FromContainerDefinition_AreObeyedAsync() { - // Arrange: the container side instructions for happy-path enforce a single word reply - // (e.g. "Always reply with exactly the single word ECHO."). See TestContainer/Program.cs. + // Arrange: the container-side happy-path instructions require every reply to end with the + // marker token CONTAINER-OK. See TestContainer/Program.cs. var agent = this._fixture.Agent; // Act @@ -205,6 +61,6 @@ public async Task Instructions_FromContainerDefinition_AreObeyedAsync() // Assert Assert.False(string.IsNullOrWhiteSpace(response.Text)); - Assert.Contains("ECHO", response.Text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("CONTAINER-OK", response.Text, StringComparison.OrdinalIgnoreCase); } } diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedResponsesStoreConfigTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedResponsesStoreConfigTests.cs new file mode 100644 index 00000000000..500ef3cec9d --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedResponsesStoreConfigTests.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Foundry.Hosting.IntegrationTests.Fixtures; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 // Experimental Responses API surfaces + +namespace Foundry.Hosting.IntegrationTests; + +/// +/// Store and session semantics for a hosted Responses agent: store=true vs store=false, +/// previous_response_id and conversation_id forks, and multi-turn recall. Stored +/// hosted-agent responses are read through the per-agent endpoint client (the project-level client +/// returns 403 session_not_accessible). +/// +[Trait("Category", "FoundryHostedAgents")] +public sealed class HostedResponsesStoreConfigTests(HostedResponsesStoreConfigFixture fixture) : IClassFixture +{ + private readonly HostedResponsesStoreConfigFixture _fixture = fixture; + + [Fact] + public async Task StoredTrue_Default_PersistsResponseInChainAsync() + { + // Arrange + var agent = this._fixture.Agent; + + // Act + var response = await agent.RunAsync("Reply with the word 'ack'."); + + // Assert + Assert.False(string.IsNullOrWhiteSpace(response.Text)); + // Stored hosted-agent responses are readable only through the per-agent endpoint client. + var fetched = await this._fixture.AgentOpenAIClient.GetProjectResponsesClient().GetResponseAsync(response.ResponseId!); + Assert.NotNull(fetched.Value); + } + + [Fact] + public async Task StoredFalse_Baseline_DoesNotPersistResponseAsync() + { + // Arrange + var agent = this._fixture.Agent; + var options = new ChatClientAgentRunOptions(new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false } + }); + + // Act + var response = await agent.RunAsync("Reply with the word 'pong'.", options: options); + + // Assert: response returned but the response id is not retrievable from the chain. + Assert.False(string.IsNullOrWhiteSpace(response.Text)); + var responseId = response.ResponseId; + Assert.False(string.IsNullOrWhiteSpace(responseId)); + + // Attempting to fetch the response should fail because nothing was stored. Reads go through + // the per-agent endpoint client (the project-level client returns 403 session_not_accessible). + await Assert.ThrowsAnyAsync(() => + this._fixture.AgentOpenAIClient.GetProjectResponsesClient().GetResponseAsync(responseId)); + } + + [Fact] + public async Task StoredFalse_WithPreviousResponseId_ReadsForkHistoryAndDoesNotAppendAsync() + { + // Contract: a store=true head establishes a retrievable fork that carries history. store=false + // continuations chained to it via previous_response_id read that history transparently but + // never persist their own response, so any number of store=false turns can reuse the same fork + // without appending to it. + var agent = this._fixture.Agent; + var perAgent = this._fixture.AgentOpenAIClient.GetProjectResponsesClient(); + + // Head turn: store=true (default). Establishes the fork with a fact and is retrievable. + var head = await agent.RunAsync("Remember the number 73. Acknowledge briefly."); + var headId = head.ResponseId; + Assert.False(string.IsNullOrWhiteSpace(headId)); + var fetchedHead = await perAgent.GetResponseAsync(headId!); + Assert.NotNull(fetchedHead.Value); + + ChatClientAgentRunOptions ContinuationOnFork() => new(new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions + { + StoredOutputEnabled = false, + PreviousResponseId = headId, + }, + }); + + // First store=false continuation: reads the fork history (recalls 73) but is not persisted. + var c1 = await agent.RunAsync("What number did I just tell you?", options: ContinuationOnFork()); + Assert.Contains("73", c1.Text); + Assert.NotEqual(headId, c1.ResponseId); + await Assert.ThrowsAnyAsync(() => perAgent.GetResponseAsync(c1.ResponseId!)); + + // Second store=false continuation on the SAME fork: still recalls, still does not append. + var c2 = await agent.RunAsync("State that number one more time.", options: ContinuationOnFork()); + Assert.Contains("73", c2.Text); + await Assert.ThrowsAnyAsync(() => perAgent.GetResponseAsync(c2.ResponseId!)); + + // The fork head is unchanged and still retrievable after the store=false continuations. + var fetchedHeadAgain = await perAgent.GetResponseAsync(headId!); + Assert.NotNull(fetchedHeadAgain.Value); + } + + [Fact] + public async Task StoredFalse_WithConversationId_ReadsHistoryButDoesNotAppendAsync() + { + // Conversation-id analog of the previous_response_id fork contract: a store=true head populates + // the conversation with history; store=false continuations bound to the same conversation read + // that history transparently but never append to it, so the conversation can be reused by any + // number of store=false turns without growing. + var agent = this._fixture.Agent; + var conversationId = await this._fixture.CreateConversationAsync(); + try + { + var stored = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId }); + ChatClientAgentRunOptions ContinuationOnConversation() => new(new ChatOptions + { + ConversationId = conversationId, + RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }, + }); + + // Head turn: store=true, populates the conversation with a fact. + await agent.RunAsync("Remember the number 99. Acknowledge briefly.", options: stored); + var afterHeadCount = await this._fixture.CountConversationItemsAsync(conversationId); + Assert.True(afterHeadCount > 0); + + // First store=false continuation: reads the conversation history (recalls 99) but does not append. + var c1 = await agent.RunAsync("What number did I just tell you?", options: ContinuationOnConversation()); + Assert.Contains("99", c1.Text); + Assert.Equal(afterHeadCount, await this._fixture.CountConversationItemsAsync(conversationId)); + + // Second store=false continuation on the SAME conversation: still recalls, still does not append. + var c2 = await agent.RunAsync("State that number one more time.", options: ContinuationOnConversation()); + Assert.Contains("99", c2.Text); + Assert.Equal(afterHeadCount, await this._fixture.CountConversationItemsAsync(conversationId)); + } + finally + { + await this._fixture.DeleteConversationAsync(conversationId); + } + } + + [Fact] + public async Task MultiTurn_WithPreviousResponseId_PreservesContextAsync() + { + // Arrange + var agent = this._fixture.Agent; + var session = await agent.CreateSessionAsync(); + + // Act + var first = await agent.RunAsync("My favorite number is 42. Acknowledge briefly.", session); + Assert.False(string.IsNullOrWhiteSpace(first.Text)); + + var second = await agent.RunAsync("What number did I just tell you?", session); + + // Assert + Assert.Contains("42", second.Text); + } + + [Fact] + public async Task MultiTurn_WithPreviousResponseId_RecoversAcrossThreeTurnsAsync() + { + // Arrange: recover the conversation across three turns purely via the previous_response_id + // chain (store=true). Each turn must land on the same hosted MAF session so earlier facts hold. + var agent = this._fixture.Agent; + var session = await agent.CreateSessionAsync(); + + // Act + var t1 = await agent.RunAsync("Remember two facts: my dog is named Rex and I live in Lisbon. Acknowledge briefly.", session); + Assert.False(string.IsNullOrWhiteSpace(t1.Text)); + var t2 = await agent.RunAsync("What is my dog's name?", session); + var t3 = await agent.RunAsync("Which city do I live in?", session); + + // Assert: both facts survive across the chain. + Assert.Contains("Rex", t2.Text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Lisbon", t3.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task MultiTurn_WithConversationId_PreservesContextAsync() + { + // Arrange + var agent = this._fixture.Agent; + var conversationId = await this._fixture.CreateConversationAsync(); + try + { + var options = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId }); + + // Act + var first = await agent.RunAsync("My favorite color is teal. Acknowledge briefly.", options: options); + Assert.False(string.IsNullOrWhiteSpace(first.Text)); + + var second = await agent.RunAsync("What color did I just tell you?", options: options); + + // Assert + Assert.Contains("teal", second.Text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await this._fixture.DeleteConversationAsync(conversationId); + } + } +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md index 3120e002624..589bd6cd060 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md @@ -46,7 +46,7 @@ etc.). Hosted agent invocation requires the agent's own managed identity to hold the `Azure AI User` role on the project scope. Because each agent's MI is created when the agent is first provisioned (and recycled on agent delete), the bootstrap creates the -six stable scenario agents once and grants the role to each MI. The fixture then only +eleven stable scenario agents once and grants the role to each MI. The fixture then only manages versions under those existing agents, so the role grants survive across runs. ```powershell @@ -148,9 +148,11 @@ $env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-4o" dotnet test dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj ``` -> **Note:** all tests are currently tagged `[Fact(Skip = ...)]` until end to end smoke -> verification has run against a live Foundry deployment. Once a scenario has been -> exercised and the assertions stabilized, remove the Skip annotation on its tests. +> **Note:** some scenarios are validated and active (for example `happy-path`, +> `store-config`, `tool-calling`, `azure-search-rag`, `session-files`); the remaining +> scenarios stay tagged `[Fact(Skip = ...)]` until they have been exercised end to end +> against a live Foundry deployment. Once a scenario has been exercised and its assertions +> stabilized, remove the Skip annotation on its tests. All test classes carry `[Trait("Category", "FoundryHostedAgents")]` so the CI workflow can route them to a separate Foundry project than the rest of the integration tests (see @@ -204,16 +206,19 @@ human-only operation; CI only adds and deletes versions under existing agents. | Fixture | `IT_SCENARIO` | Agent name | What it tests | | --- | --- | --- | --- | -| `HappyPathHostedAgentFixture` | `happy-path` | `it-happy-path` | Round trip, streaming, multi turn (`previous_response_id` and `conversation_id`), `stored=false` flag in three combinations, instructions obeyed. | +| `HappyPathHostedAgentFixture` | `happy-path` | `it-happy-path` | Round trip, streaming, and container-instruction behaviour. | +| `HostedResponsesStoreConfigFixture` | `store-config` | `it-store-config` | Store/session semantics: `store=true` vs `store=false`, `previous_response_id` and `conversation_id` forks (read history without appending), multi-turn recall. | | `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. | | `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. | | `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). | | `ToolboxOAuthConsentHostedAgentFixture` | `toolbox-oauth-consent` | `it-toolbox-oauth-consent` | Per-user OAuth toolbox consent: pre-registers a consent-gated Foundry toolbox (`IT_TOOLBOX_NAME`), invokes the agent, asserts the consumer captures an `oauth_consent_request` consent link (and the container stays routable, no 424). Requires a consent-gated toolbox in the project (see prerequisite below). | | `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). | +| `MemoryHostedAgentFixture` | `memory` | `it-memory` | `FoundryMemoryProvider` (scoped via `HostedSessionContext`) running inside the hosted agent recalls user preferences across multiple turns; the memory store name is randomised per fixture (`IT_MEMORY_STORE_ID`). | | `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. | | `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. | | `AgentSkillsHostedAgentFixture` | `agent-skills` | `it-agent-skills` | Agent skills via `AgentSkillsProvider`: advertises two Contoso Outdoors skills (support-style, escalation-policy) in the system prompt, loads them on demand via `load_skill`, verifies canary tokens prove the skill was loaded. | -The placeholder scenarios will be wired up in the test container `Program.cs` once the -relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize. +The scenarios marked (placeholder) are already wired into the test container `Program.cs`, +but their assertions stay skipped pending live validation and stabilization of the relevant +`Microsoft.Agents.AI.Foundry.Hosting` API surfaces. 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 6500bd3c0bf..7007c987a30 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 @@ -41,6 +41,7 @@ $ErrorActionPreference = 'Stop' $Scenarios = @( 'happy-path', + 'store-config', 'tool-calling', 'tool-calling-approval', 'mcp-toolbox', diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs index 5c71e9bbe89..8cba96ce2c3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -876,5 +876,183 @@ protected override ValueTask DeserializeSessionCoreAsync( new(new SimpleAgentSession()); } + [Fact] + public async Task CreateAsync_PreviousResponseIdChain_NoConversation_ReusesOneSessionAsync() + { + // Arrange + var agent = new SessionCountingAgent(); + var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice"); + var store = new InMemoryAgentSessionStore(); + var handler = BuildHandlerWith(agent, fakeProvider, store); + + const string PartitionA = "aaaaaaaaaaaaaaaa00"; + var responseA = "caresp_" + PartitionA + new string('1', 32); + var responseA2 = "caresp_" + PartitionA + new string('2', 32); + + // Turn 1: cold start, no conversation, no previous_response_id. Key from minted responseA. + var (req1, ctx1) = BuildChainRequest(responseA, callId: null); + await DrainEventsAsync(handler.CreateAsync(req1, ctx1.Object, CancellationToken.None)); + Assert.NotNull(agent.LastSession); + + // Turn 2: client echoes previous_response_id sharing the same partition; minted responseA2. + var (req2, ctx2) = BuildChainRequest(responseA2, callId: null); + req2.PreviousResponseId = responseA; + agent.LastSession = null; + await DrainEventsAsync(handler.CreateAsync(req2, ctx2.Object, CancellationToken.None)); + + // Assert: both turns persisted under the same partition key → one created session. + Assert.NotNull(agent.LastSession); + Assert.Equal("alice", agent.LastSession!.GetHostedContext()!.UserId); + Assert.Equal(1, agent.SessionCount); + } + + [Fact] + public async Task CreateAsync_SetsCallIdFromPlatformContext_VisibleDuringAgentRunAsync() + { + // Arrange + var agent = new CallIdCapturingAgent(); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider("alice"), new InMemoryAgentSessionStore()); + var (request, ctx) = BuildChainRequest("caresp_" + new string('0', 50), callId: "call-xyz"); + + // Act + await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); + + // Assert: the call id observed inside the agent run (the same async flow that drives any + // downstream MCP/tool egress) matches the platform-provided value. This guards against the + // async-iterator AsyncLocal revert that would otherwise drop the call id before egress. + Assert.Equal("call-xyz", agent.ObservedCallId); + } + + [Fact] + public async Task CreateAsync_NoCallIdInPlatformContext_LeavesAmbientNullAsync() + { + // Arrange + var agent = new CallIdCapturingAgent(); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider("alice"), new InMemoryAgentSessionStore()); + var (request, ctx) = BuildChainRequest("caresp_" + new string('0', 50), callId: null); + + // Act + await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); + + // Assert + Assert.Null(agent.ObservedCallId); + } + + [Fact] + public async Task CreateAsync_AfterStreamCompletes_DoesNotLeakCallIdToCallerContextAsync() + { + // Arrange + var agent = new CallIdCapturingAgent(); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider("alice"), new InMemoryAgentSessionStore()); + var (request, ctx) = BuildChainRequest("caresp_" + new string('0', 50), callId: "call-xyz"); + + // The caller's ambient call id starts clear. + Assert.Null(HostedCallContext.CallId); + + // Act + await DrainEventsAsync(handler.CreateAsync(request, ctx.Object, CancellationToken.None)); + + // Assert: HostedCallContext is documented request-scoped. The handler sets the AsyncLocal inside + // its streaming iterator (observed by the agent run — see VisibleDuringAgentRun above), but that + // write never escapes to the caller's execution context. After the stream completes the caller's + // ambient call id is still null, so a stale call id cannot leak into a subsequent request that is + // handled on the same thread. + Assert.Equal("call-xyz", agent.ObservedCallId); + Assert.Null(HostedCallContext.CallId); + } + + private static AgentFrameworkResponseHandler BuildHandlerWith(AIAgent agent, HostedSessionIsolationKeyProvider provider, AgentSessionStore store) + { + var services = new ServiceCollection(); + services.AddSingleton(store); + services.AddSingleton(agent); + services.AddSingleton(provider); + return new AgentFrameworkResponseHandler(services.BuildServiceProvider(), NullLogger.Instance); + } + + private static (CreateResponse Request, Mock Context) BuildChainRequest(string responseId, string? callId) + { + var request = new CreateResponse { Model = "test" }; + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + var ctx = new Mock(responseId) { CallBase = true }; + ctx.Setup(x => x.PlatformContext).Returns(new PlatformContext("alice", callId)); + ctx.Setup(x => x.GetHistoryAsync(It.IsAny())).ReturnsAsync(Array.Empty()); + ctx.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Array.Empty()); + return (request, ctx); + } + + private static async Task DrainEventsAsync(IAsyncEnumerable stream) + { + await foreach (var _ in stream) + { + } + } + + /// Stateful agent that counts created sessions and round-trips its . + private sealed class SessionCountingAgent : AIAgent + { + public AgentSession? LastSession { get; set; } + public int SessionCount { get; private set; } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session, AgentRunOptions? options, CancellationToken cancellationToken = default) + { + this.LastSession = session; + return ToAsyncEnumerableAsync(new AgentResponseUpdate { MessageId = "resp_msg_1", Contents = [new MeaiTextContent("ok")] }); + } + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session, AgentRunOptions? options, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + { + this.SessionCount++; + return new(new StatefulSession()); + } + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) + => new(((StatefulSession)session).Serialize()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) + => new(StatefulSession.Deserialize(serializedState)); + } + + /// Reads during its run, standing in for a downstream tool call. + private sealed class CallIdCapturingAgent : AIAgent + { + public string? ObservedCallId { get; private set; } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session, AgentRunOptions? options, CancellationToken cancellationToken = default) + { + this.ObservedCallId = HostedCallContext.CallId; + return ToAsyncEnumerableAsync(new AgentResponseUpdate { MessageId = "resp_msg_1", Contents = [new MeaiTextContent("ok")] }); + } + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session, AgentRunOptions? options, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new StatefulSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) + => new(((StatefulSession)session).Serialize()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) + => new(StatefulSession.Deserialize(serializedState)); + } + + private sealed class StatefulSession : AgentSession + { + public StatefulSession() { } + private StatefulSession(AgentSessionStateBag bag) { this.StateBag = bag; } + public JsonElement Serialize() => this.StateBag.Serialize(); + public static StatefulSession Deserialize(JsonElement e) => new(AgentSessionStateBag.Deserialize(e)); + } + private sealed class SimpleAgentSession : AgentSession { } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FakeHostedSessionIsolationKeyProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FakeHostedSessionIsolationKeyProvider.cs index b73ae8da660..8dc3346ba8b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FakeHostedSessionIsolationKeyProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FakeHostedSessionIsolationKeyProvider.cs @@ -10,21 +10,20 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; /// /// Test fake that returns a non-null by default, allowing tests /// that were written before the strict isolation-key contract to keep passing without each test -/// having to stub ResponseContext.Isolation. The constructor also accepts -/// values so individual tests can exercise the handler's null-key error path. +/// having to stub ResponseContext.PlatformContext. The constructor also accepts +/// so individual tests can exercise the handler's null-key error path. /// internal sealed class FakeHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider { public const string DefaultUserId = "test-user-isolation"; - public const string DefaultChatId = "test-chat-isolation"; private readonly HostedSessionContext? _context; - public FakeHostedSessionIsolationKeyProvider(string? userId = DefaultUserId, string? chatId = DefaultChatId) + public FakeHostedSessionIsolationKeyProvider(string? userId = DefaultUserId) { - this._context = userId is null || chatId is null + this._context = userId is null ? null - : new HostedSessionContext(userId, chatId); + : new HostedSessionContext(userId); } public override ValueTask GetKeysAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs index eafa3c497a2..72861b0cd6d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs @@ -39,6 +39,46 @@ private static (FoundryToolboxBearerTokenHandler Handler, CountingHandler Inner) return (handler, inner); } + [Fact] + public async Task SendAsync_ForwardsCallIdWhenSetAsync() + { + // Arrange + var (handler, _) = CreateHandlerPair(); + using var invoker = new HttpMessageInvoker(handler); + HostedCallContext.CallId = "call-abc"; + + try + { + // Act + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + // Assert + Assert.True(request.Headers.TryGetValues("x-agent-foundry-call-id", out var values)); + Assert.Equal("call-abc", values!.Single()); + } + finally + { + HostedCallContext.CallId = null; + } + } + + [Fact] + public async Task SendAsync_OmitsCallIdWhenAbsentAsync() + { + // Arrange + var (handler, _) = CreateHandlerPair(); + using var invoker = new HttpMessageInvoker(handler); + HostedCallContext.CallId = null; + + // Act + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + // Assert + Assert.False(request.Headers.Contains("x-agent-foundry-call-id")); + } + [Fact] public async Task SendAsync_InjectsBearerTokenAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedConversationKeyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedConversationKeyTests.cs new file mode 100644 index 00000000000..06b1caa50c3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedConversationKeyTests.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// Tests for , which derives the stable per-conversation key used +/// to map a hosted request to a persisted MAF session. +/// +public class HostedConversationKeyTests +{ + // 18-char partition + 32-char entropy = 50-char body (current format). + private const string PartitionA = "aaaaaaaaaaaaaaaa00"; + private static readonly string s_responseA = "caresp_" + PartitionA + new string('1', 32); + private static readonly string s_responseA2 = "caresp_" + PartitionA + new string('2', 32); + + [Fact] + public void PartitionOf_NewFormat_ReturnsFirst18() + => Assert.Equal(PartitionA, HostedConversationKey.PartitionOf(s_responseA)); + + [Fact] + public void PartitionOf_SamePartition_AcrossChain_Matches() + => Assert.Equal(HostedConversationKey.PartitionOf(s_responseA), HostedConversationKey.PartitionOf(s_responseA2)); + + [Fact] + public void PartitionOf_LegacyFormat_ReturnsLast16() + { + var legacy = "caresp_" + new string('x', 32) + "abcdefabcdef1234"; // 48-char body + Assert.Equal("abcdefabcdef1234", HostedConversationKey.PartitionOf(legacy)); + } + + [Fact] + public void PartitionOf_Raw_WhenNoKnownLength() => Assert.Equal("conv-123", HostedConversationKey.PartitionOf("conv-123")); + + [Fact] + public void PartitionOf_NullOrWhitespace_ReturnsNull() + { + Assert.Null(HostedConversationKey.PartitionOf(null)); + Assert.Null(HostedConversationKey.PartitionOf(" ")); + } + + [Fact] + public void Resolve_PrefersConversation_ThenPrev_ThenResponse() + { + Assert.Equal("conv", HostedConversationKey.Resolve("conv", s_responseA, s_responseA2)); + Assert.Equal(PartitionA, HostedConversationKey.Resolve(null, s_responseA, "caresp_" + new string('9', 50))); + Assert.Equal(PartitionA, HostedConversationKey.Resolve(null, null, s_responseA)); + Assert.Null(HostedConversationKey.Resolve(null, null, null)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedFoundryMemoryProviderScopesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedFoundryMemoryProviderScopesTests.cs index 46f2c236d23..e14190d406a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedFoundryMemoryProviderScopesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedFoundryMemoryProviderScopesTests.cs @@ -10,13 +10,12 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; public class HostedFoundryMemoryProviderScopesTests { private const string TestUserId = "user-isolation-key-1"; - private const string TestChatId = "chat-isolation-key-1"; [Fact] public void PerUser_UsesUserIdAsScope() { // Arrange - var session = CreateTaggedSession(TestUserId, TestChatId); + var session = CreateTaggedSession(TestUserId); var initializer = HostedFoundryMemoryProviderScopes.PerUser(); // Act @@ -27,80 +26,6 @@ public void PerUser_UsesUserIdAsScope() Assert.Equal(TestUserId, state.Scope.Scope); } - [Fact] - public void PerChat_UsesChatIdAsScope() - { - // Arrange - var session = CreateTaggedSession(TestUserId, TestChatId); - var initializer = HostedFoundryMemoryProviderScopes.PerChat(); - - // Act - var state = initializer(session); - - // Assert - Assert.NotNull(state); - Assert.Equal(TestChatId, state.Scope.Scope); - } - - [Fact] - public void PerUserAndChat_ComposesUserAndChatWithEscapedSeparator() - { - // Arrange - var session = CreateTaggedSession(TestUserId, TestChatId); - var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat(); - - // Act - var state = initializer(session); - - // Assert - Assert.NotNull(state); - Assert.Equal($"{TestUserId}::{TestChatId}", state.Scope.Scope); - } - - [Fact] - public void PerUserAndChat_EscapesColonsInUserAndChat() - { - // Arrange - var session = CreateTaggedSession("alice:finance", "q2:final"); - var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat(); - - // Act - var state = initializer(session); - - // Assert - colons inside each part are escaped as \: , parts joined with :: - Assert.Equal(@"alice\:finance::q2\:final", state.Scope.Scope); - } - - [Fact] - public void PerUserAndChat_EscapesBackslashesInUserAndChat() - { - // Arrange - var session = CreateTaggedSession(@"alice\corp", @"chat\1"); - var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat(); - - // Act - var state = initializer(session); - - // Assert - backslashes escaped first as \\ , parts joined with :: - Assert.Equal(@"alice\\corp::chat\\1", state.Scope.Scope); - } - - [Fact] - public void PerUserAndChat_DistinctContextsDoNotCollide() - { - // Arrange - two distinct (UserId, ChatId) pairs that collide under raw-colon composition. - var sessionA = CreateTaggedSession("alice:finance", "q2"); - var sessionB = CreateTaggedSession("alice", "finance:q2"); - var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat(); - - // Act - var scopeA = initializer(sessionA).Scope.Scope; - var scopeB = initializer(sessionB).Scope.Scope; - - // Assert - Assert.NotEqual(scopeA, scopeB); - } - [Fact] public void PerUser_NullSession_Throws() { @@ -112,26 +37,6 @@ public void PerUser_NullSession_Throws() Assert.Contains(nameof(HostedSessionContext), ex.Message); } - [Fact] - public void PerChat_NullSession_Throws() - { - // Arrange - var initializer = HostedFoundryMemoryProviderScopes.PerChat(); - - // Act & Assert - Assert.Throws(() => initializer(null)); - } - - [Fact] - public void PerUserAndChat_NullSession_Throws() - { - // Arrange - var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat(); - - // Act & Assert - Assert.Throws(() => initializer(null)); - } - [Fact] public void PerUser_SessionWithoutHostedContext_Throws() { @@ -144,10 +49,10 @@ public void PerUser_SessionWithoutHostedContext_Throws() Assert.Contains(nameof(HostedFoundryMemoryProviderScopes), ex.Message); } - private static BareAgentSession CreateTaggedSession(string userId, string chatId) + private static BareAgentSession CreateTaggedSession(string userId) { var session = new BareAgentSession(); - session.SetHostedContext(new HostedSessionContext(userId, chatId)); + session.SetHostedContext(new HostedSessionContext(userId)); return session; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedFoundryMemoryProviderServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedFoundryMemoryProviderServiceCollectionExtensionsTests.cs index e7478ee195f..dca5e99db61 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedFoundryMemoryProviderServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedFoundryMemoryProviderServiceCollectionExtensionsTests.cs @@ -13,7 +13,6 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; public class HostedFoundryMemoryProviderServiceCollectionExtensionsTests { private const string TestUserId = "ext-user-1"; - private const string TestChatId = "ext-chat-1"; private const string MemoryStoreName = "test-memory-store"; [Fact] @@ -107,7 +106,7 @@ private static AIProjectClient CreateClient() private static BareAgentSession CreateTaggedSession() { var session = new BareAgentSession(); - session.SetHostedContext(new HostedSessionContext(TestUserId, TestChatId)); + session.SetHostedContext(new HostedSessionContext(TestUserId)); return session; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs index 6d29b1355b9..d5165fadaa1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs @@ -21,16 +21,14 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; public class HostedSessionIdentityContextTests { private const string TestUserId = "user-isolation-key-1"; - private const string TestChatId = "chat-isolation-key-1"; [Fact] public void HostedSessionContext_RejectsNullOrWhitespaceKeys() { // Assert - Assert.Throws(() => new HostedSessionContext(null!, TestChatId)); - Assert.Throws(() => new HostedSessionContext(TestUserId, null!)); - Assert.Throws(() => new HostedSessionContext(string.Empty, TestChatId)); - Assert.Throws(() => new HostedSessionContext(TestUserId, " ")); + Assert.Throws(() => new HostedSessionContext(null!)); + Assert.Throws(() => new HostedSessionContext(string.Empty)); + Assert.Throws(() => new HostedSessionContext(" ")); } [Fact] @@ -39,7 +37,7 @@ public async Task PlatformProvider_MapsIsolationContextValuesAsync() // Arrange var provider = new PlatformHostedSessionIsolationKeyProvider(); var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; - mockContext.Setup(x => x.Isolation).Returns(new IsolationContext(TestUserId, TestChatId)); + mockContext.Setup(x => x.PlatformContext).Returns(new PlatformContext(TestUserId, "call-1")); var request = new CreateResponse { Model = "test" }; // Act @@ -48,7 +46,6 @@ public async Task PlatformProvider_MapsIsolationContextValuesAsync() // Assert Assert.NotNull(result); Assert.Equal(TestUserId, result.UserId); - Assert.Equal(TestChatId, result.ChatId); } [Fact] @@ -57,7 +54,7 @@ public async Task PlatformProvider_ReturnsNullWhenIsolationKeysAreEmptyAsync() // Arrange var provider = new PlatformHostedSessionIsolationKeyProvider(); var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; - // CallBase delegates to ResponseContext.Isolation default which is IsolationContext.Empty. + // CallBase delegates to ResponseContext.PlatformContext default which is PlatformContext.Empty. var request = new CreateResponse { Model = "test" }; // Act @@ -72,7 +69,7 @@ public async Task Handler_FreshSession_AppliesContextFromCustomProviderAsync() { // Arrange var capturingAgent = new HostedContextCapturingAgent(); - var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A"); + var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice"); var handler = BuildHandler(capturingAgent, fakeProvider); var (request, mockContext) = BuildFreshRequest(); @@ -85,7 +82,6 @@ public async Task Handler_FreshSession_AppliesContextFromCustomProviderAsync() var ctx = capturingAgent.LastSession.GetHostedContext(); Assert.NotNull(ctx); Assert.Equal("alice", ctx.UserId); - Assert.Equal("chat-A", ctx.ChatId); } [Fact] @@ -93,7 +89,7 @@ public async Task Handler_NullKeysFromProvider_ThrowsInvalidOperationAsync() { // Arrange var capturingAgent = new HostedContextCapturingAgent(); - var fakeProvider = new FakeHostedSessionIsolationKeyProvider(userId: null, chatId: null); + var fakeProvider = new FakeHostedSessionIsolationKeyProvider(userId: null); var handler = BuildHandler(capturingAgent, fakeProvider); var (request, mockContext) = BuildFreshRequest(); @@ -108,7 +104,7 @@ public async Task Handler_ResumeSession_MatchingKeys_PassesAsync() { // Arrange var capturingAgent = new HostedContextCapturingAgent(); - var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A"); + var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice"); var sessionStore = new InMemoryAgentSessionStore(); var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore); @@ -141,7 +137,7 @@ public async Task Handler_ResumeSession_MismatchedUserId_Returns403Async() { // Arrange var capturingAgent = new HostedContextCapturingAgent(); - var aliceProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A"); + var aliceProvider = new FakeHostedSessionIsolationKeyProvider("alice"); var sessionStore = new InMemoryAgentSessionStore(); var aliceHandler = BuildHandler(capturingAgent, aliceProvider, sessionStore); @@ -151,7 +147,7 @@ public async Task Handler_ResumeSession_MismatchedUserId_Returns403Async() await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, CancellationToken.None); // Bob attempts to resume Alice's conversation. - var bobProvider = new FakeHostedSessionIsolationKeyProvider("bob", "chat-A"); + var bobProvider = new FakeHostedSessionIsolationKeyProvider("bob"); var bobHandler = BuildHandler(capturingAgent, bobProvider, sessionStore); var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId); @@ -161,29 +157,6 @@ public async Task Handler_ResumeSession_MismatchedUserId_Returns403Async() Assert.Equal("Hosted session identity context mismatch", ex.Error.Message); } - [Fact] - public async Task Handler_ResumeSession_MismatchedChatId_Returns403Async() - { - // Arrange - var capturingAgent = new HostedContextCapturingAgent(); - var chatAProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A"); - var sessionStore = new InMemoryAgentSessionStore(); - var chatAHandler = BuildHandler(capturingAgent, chatAProvider, sessionStore); - - var (freshRequest, freshContext) = BuildFreshRequest(); - await DrainAsync(chatAHandler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None)); - const string ConversationId = "resume-chat-id"; - await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, CancellationToken.None); - - var chatBProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-B"); - var chatBHandler = BuildHandler(capturingAgent, chatBProvider, sessionStore); - var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId); - - // Act & Assert - var ex = await Assert.ThrowsAsync(() => DrainAsync(chatBHandler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None))); - Assert.Equal(403, ex.StatusCode); - } - [Fact] public async Task Handler_ResumeSession_WithoutPriorContext_StampsAsFreshAsync() { @@ -198,7 +171,7 @@ public async Task Handler_ResumeSession_WithoutPriorContext_StampsAsFreshAsync() var untagged = await capturingAgent.CreateSessionAsync(CancellationToken.None); await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, untagged, CancellationToken.None); - var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A"); + var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice"); var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore); var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId); @@ -210,7 +183,6 @@ public async Task Handler_ResumeSession_WithoutPriorContext_StampsAsFreshAsync() var ctx = capturingAgent.LastSession.GetHostedContext(); Assert.NotNull(ctx); Assert.Equal("alice", ctx.UserId); - Assert.Equal("chat-A", ctx.ChatId); } [Fact] @@ -233,13 +205,12 @@ public void SetHostedContext_ThenGet_RoundTrips() var session = new HostedContextCapturingSession(); // Act - session.SetHostedContext(new HostedSessionContext("alice", "chat-A")); + session.SetHostedContext(new HostedSessionContext("alice")); var ctx = session.GetHostedContext(); // Assert Assert.NotNull(ctx); Assert.Equal("alice", ctx.UserId); - Assert.Equal("chat-A", ctx.ChatId); } private static AgentFrameworkResponseHandler BuildHandler(