mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* .NET: Add Hosted-MemoryAgent sample with isolation key plumbing (#5692) Adds HostedSessionContext + HostedSessionIsolationKeyProvider in Microsoft.Agents.AI.Foundry.Hosting so AIContextProviders (notably FoundryMemoryProvider) can scope per user via the platform's x-agent-user-isolation-key / x-agent-chat-isolation-key headers. - New types: HostedSessionContext (sealed), HostedSessionContextExtensions (public Get, internal Set), abstract HostedSessionIsolationKeyProvider (async), internal PlatformHostedSessionIsolationKeyProvider mapping ResponseContext.Isolation. - AgentFrameworkResponseHandler now resolves the provider, tags fresh sessions, and validates resumed sessions against the live request (strict 403 'Hosted session identity context mismatch' on any mismatch; 500 on null keys). - New shared sample project Hosted_Shared_Contributor_Setup hosts DevTemporaryTokenCredential and DevTemporaryLocalSessionIsolationKeyProvider plus AddDevTemporaryLocalContributorSetup. All 9 existing responses samples migrated to consume it so local runs keep working under the strict isolation contract. - New Hosted-MemoryAgent sample: travel assistant wired through FoundryMemoryProvider with stateInitializer reading session.GetHostedContext().UserId. Includes Dockerfile, smoke.ps1, agent.yaml/manifest. - New IT scenario 'memory' in Foundry.Hosting.IntegrationTests + MemoryHostedAgentFixture + MemoryHostedAgentTests. Verified end to end against the tao Foundry project. - ADR 0026 captures the design tree. * Address PR review feedback - Dockerfile: add header noting it targets NuGet builds; contributors must use Dockerfile.contributor for ProjectReference source builds. - PlatformHostedSessionIsolationKeyProvider: doc said 'returns context with empty values'; corrected to 'returns null' which the handler treats as 500. - FakeHostedSessionIsolationKeyProvider: doc clarifies that null configurations are allowed for testing the handler error path. - HostedSessionContextExtensions.SetHostedContext: enforce write-once with InvalidOperationException; doc + xml exception updated. - AgentFrameworkResponseHandler: cache PlatformHostedSessionIsolationKeyProvider as static readonly to avoid per-request allocation. - MemoryHostedAgentTests: tighten waits from 20s to 5s (FoundryMemoryProvider defaults UpdateDelay=0; ingestion ~3s). - Sample Program.cs imports reordered to satisfy IDE0005. * Add HostedFoundryMemoryProviderScopes built-in helpers (#5692) Addresses review feedback from @lokitoth on Hosted-MemoryAgent/Program.cs:54. - New HostedFoundryMemoryProviderScopes static class with PerUser, PerChat, PerUserAndChat factories returning Func<AgentSession?, FoundryMemoryProvider.State>. - All helpers throw InvalidOperationException when GetHostedContext() is null, with a message pointing at writing a custom stateInitializer for non-hosted scenarios. - New HostedFoundryMemoryScope enum and AddHostedFoundryMemoryProvider DI extension (two overloads: explicit AIProjectClient and DI-resolved). Singleton lifetime. Default scope = PerUser. - Hosted-MemoryAgent sample and the memory IT scenario container both swap their inline lambdas for HostedFoundryMemoryProviderScopes.PerUser(). - 14 new unit tests (241/241 hosting unit tests pass). * Replace HostedFoundryMemoryScope enum with Func<...> parameter (#5692) Address PR review feedback from @westey-m: enums are a breaking-change hazard when extended, and the enum was redundant with the existing HostedFoundryMemoryProviderScopes static class. - Delete HostedFoundryMemoryScope.cs. - AddHostedFoundryMemoryProvider DI extensions now take Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null. When null, default to HostedFoundryMemoryProviderScopes.PerUser(). - Callers pick a built-in helper (PerUser/PerChat/PerUserAndChat) or pass a custom delegate. New built-ins are a single static method addition with zero impact on existing callers. - Tests updated; 244/244 hosting unit tests pass. * Fix isolation context resume for externally-created conversations (#5692) Branch on the session's existing hosted-context (not on conversation_id presence) so a conversation provisioned externally (e.g. via conversations.CreateProjectConversationAsync) is treated as fresh on first hosted-agent request and stamped, rather than rejected with 403 hosted_session_identity_mismatch. Strict equality is preserved on real resume of an already-stamped session. Also tighten dotnet/global.json to version 10.0.204 + rollForward latestPatch so local builds match the CI Docker image SDK and avoid 10.0.300 dotnet format stripping required usings. * Revert global.json SDK pin to upstream (#5692) The 10.0.204 + latestPatch pin from the previous commit broke the dotnet-format CI job (hostfxr_resolve_sdk2 could not find a compatible SDK in the mcr.microsoft.com/dotnet/sdk:10.0 image). Restore upstream 10.0.200 + minor; local Release builds with SDK 10.0.300 should set GITHUB_ACTIONS=true to bypass the auto-format-on-build target.
This commit is contained in:
committed by
GitHub
Unverified
parent
97eaef029e
commit
ad95f2f2fa
@@ -0,0 +1,84 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-05-07
|
||||
deciders: rogerbarreto
|
||||
consulted: []
|
||||
informed: []
|
||||
---
|
||||
|
||||
# Hosted session identity context for Foundry Hosting
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Server-hosted Foundry agents need a way to scope per-user state (most notably `FoundryMemoryProvider` memories) by the end user that initiated the request. The Foundry platform already injects `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every Responses request, but the agent-framework hosting layer did not surface those values to `AIContextProvider` instances. The provider's `stateInitializer` only received an `AgentSession?` with no identity attached, so per-user scoping was impossible without out-of-band plumbing.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Memory and any future user-private context must be partitioned per end user without per-sample boilerplate.
|
||||
- The identity must be **read-only** from the perspective of `AIContextProvider`s, so a buggy or hostile provider cannot escalate or leak across users.
|
||||
- The persisted session must validate against the live request on every resume to defend against session-id leak and in-process tampering.
|
||||
- The change must work for every existing hosted-agent type (`ChatClientAgent`, `FoundryAgent`, future ones) without per-type refactoring of cast-heavy code paths in `Microsoft.Agents.AI`.
|
||||
- Local Docker debugging must remain possible when the platform headers are absent.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **`HostedSessionContext` stored in `AgentSessionStateBag`, exposed via a public read accessor and an `internal` setter.** Hosting writes once on session creation and validates on every resume.
|
||||
2. **Specialised `HostedAgentSession : AgentSession` wrapper** that carries `UserId`/`ChatId` properties, with `GetService<ChatClientAgentSession>()` as the unwrap escape hatch.
|
||||
3. **New property on `AgentSession` base class** (`HostedSessionContext? HostedContext { get; internal set; }`).
|
||||
4. **AsyncLocal middleware** that reads the headers and stuffs them into a per-request `AsyncLocal<HostedSessionContext>` consumed by the provider.
|
||||
|
||||
For the source of identity:
|
||||
- A. The platform-injected `IsolationContext` exposed by `ResponseContext.Isolation` (typed `UserIsolationKey`/`ChatIsolationKey`).
|
||||
- B. The OpenAI Responses spec's top-level `request.User` field.
|
||||
- C. A custom HTTP header `x-client-user`.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Option 1** was chosen for the storage shape, sourced from **Option A** (`ResponseContext.Isolation`).
|
||||
|
||||
Rationale:
|
||||
|
||||
- **Wrapper rejected (Option 2).** `ChatClientAgentSession` is `sealed` and `ChatClientAgent` rejects any other session type via direct `is not ChatClientAgentSession` checks at multiple call sites. Wrapping would force non-trivial refactors across `Microsoft.Agents.AI` and a corresponding repeat for every other agent type.
|
||||
- **Base-class property rejected (Option 3).** Leaks "hosted" semantics into the universal `AgentSession` abstraction used by Durable, A2A, and CopilotStudio agents that have no notion of a hosted user.
|
||||
- **AsyncLocal rejected (Option 4).** Surfaces the concept only locally, requires every consumer to re-implement the bridge, and cannot be enforced as read-only.
|
||||
- **`request.User` rejected (Option B).** Set by the caller, not the platform. Forging it client-side trivially defeats per-user partitioning.
|
||||
- **`x-client-user` rejected (Option C).** Non-standard, requires custom HTTP plumbing, and duplicates the platform-provided isolation contract.
|
||||
|
||||
Implementation summary in `Microsoft.Agents.AI.Foundry.Hosting`:
|
||||
|
||||
| Type | Visibility | Purpose |
|
||||
|---|---|---|
|
||||
| `HostedSessionContext` | public sealed | Captures `UserId` and `ChatId` (both required, non-whitespace). |
|
||||
| `HostedSessionContextExtensions.GetHostedContext` | public | Read accessor for `AIContextProvider`s. |
|
||||
| `HostedSessionContextExtensions.SetHostedContext` | internal | Writer reserved for the hosting assembly. Backed by `AgentSessionStateBag` under a well-known key for serialisation. |
|
||||
| `HostedSessionIsolationKeyProvider` (abstract) | public | DI-resolvable factory. Async signature: `ValueTask<HostedSessionContext?> GetKeysAsync(ResponseContext, CreateResponse, CancellationToken)`. |
|
||||
| `PlatformHostedSessionIsolationKeyProvider` | internal sealed | Default implementation. Maps `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. Returns `null` when either is absent. |
|
||||
|
||||
Behaviour added to `AgentFrameworkResponseHandler.CreateAsync`:
|
||||
|
||||
1. Resolve `HostedSessionIsolationKeyProvider` from DI; fall back to `PlatformHostedSessionIsolationKeyProvider`.
|
||||
2. Call `GetKeysAsync(context, request, cancellationToken)`. A `null` result throws `InvalidOperationException` (becomes 500). A null/whitespace `UserId` or `ChatId` is rejected by `HostedSessionContext`'s constructor.
|
||||
3. Branch on the **session's existing context**, not on whether a `conversation_id` was supplied:
|
||||
- **No session (`session is null`):** nothing to stamp; skip.
|
||||
- **Session present but un-stamped (`GetHostedContext() is null`):** treat as fresh. This covers both newly-created sessions and pre-existing sessions whose `conversation_id` was provisioned externally (e.g. via `conversations.CreateProjectConversationAsync()`) before the first hosted-agent request. Stamp the resolved identity now.
|
||||
- **Session present with stamped context:** strict resume. The persisted `UserId` and `ChatId` must equal the resolved values exactly. Mismatch throws `ResponsesApiException` with status 403 and body `Hosted session identity context mismatch`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Per-user memory partitioning works out of the box for any agent that consumes a `Microsoft.Agents.AI.Foundry.FoundryMemoryProvider` configured to read `session.GetHostedContext().UserId`.
|
||||
- Cross-user session-id leak and in-process tampering of the persisted identity both surface as a 403 with a deliberately uninformative body.
|
||||
- The identity is opaque to the framework, matching the platform's semantics. The framework never inspects user identity; the `IsolationContext` keys are pre-partitioned per agent.
|
||||
|
||||
Negative:
|
||||
|
||||
- Every existing hosted sample fails locally without a `HostedSessionIsolationKeyProvider` registered, because the platform headers are absent outside the platform. Mitigated by shipping `Hosted_Shared_Contributor_Setup` with `DevTemporaryLocalSessionIsolationKeyProvider` and `AddDevTemporaryLocalContributorSetup`, and migrating all 9 existing responses samples.
|
||||
- An attacker who can plant an un-stamped session under a victim's `conversation_id` *before* the victim's first hosted-agent request would be stamped with the attacker's identity on that first request. This is not a regression vs. behaviour without this contract, and is mitigated in practice because the `conversation_id` namespace is allocated by the platform per project. Once a session is stamped, the strict equality check fully defends the resume path.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-request `User` field on `CreateResponse` is intentionally not consumed; only the platform `IsolationContext` headers carry trustworthy identity.
|
||||
- Generic (non-Foundry) hosting layers can re-define an equivalent type if needed; nothing in this ADR is moved into `Microsoft.Agents.AI.Hosting` because `Microsoft.Agents.AI.Foundry.Hosting` does not depend on it.
|
||||
- HMAC tamper signatures over the persisted context are not implemented; comparison against `ResponseContext.Isolation` on every request is sufficient because the platform sets those headers at the trust boundary.
|
||||
@@ -327,9 +327,15 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/HostedMemoryAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
|
||||
+1
@@ -18,6 +18,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+2
-45
@@ -4,6 +4,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -40,6 +41,7 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -51,48 +53,3 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(this.GetAccessToken());
|
||||
}
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -18,6 +18,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+2
-45
@@ -5,6 +5,7 @@ using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -33,6 +34,7 @@ FoundryAgent agent = aiProjectClient.AsAIAgent(agentRecord);
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -44,48 +46,3 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(this.GetAccessToken());
|
||||
}
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+2
-36
@@ -11,6 +11,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -112,6 +113,7 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -126,39 +128,3 @@ app.Run();
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location);
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -21,6 +21,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
@@ -19,6 +19,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -81,6 +82,7 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -92,39 +94,3 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample
|
||||
AGENT_NAME=hosted-memory-agent
|
||||
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
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
|
||||
#
|
||||
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
|
||||
# which only succeeds when the project references its dependencies via PackageReference (see the
|
||||
# commented-out section in HostedMemoryAgent.csproj). Contributors building from the
|
||||
# agent-framework repository source must use Dockerfile.contributor instead because
|
||||
# ProjectReference dependencies live outside this folder and cannot be restored from inside
|
||||
# this build context.
|
||||
#
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedMemoryAgent.dll"]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-memory-agent .
|
||||
# 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
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedMemoryAgent.dll"]
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedMemoryAgent</RootNamespace>
|
||||
<AssemblyName>HostedMemoryAgent</AssemblyName>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted-MemoryAgent
|
||||
//
|
||||
// Demonstrates how to host an agent that uses FoundryMemoryProvider so that user-private memories
|
||||
// persist across requests and across sessions, scoped per user via the Foundry platform's
|
||||
// isolation key headers.
|
||||
//
|
||||
// Memory scope flows from request -> hosting layer -> session -> provider:
|
||||
// 1. Foundry sets x-agent-user-isolation-key on every inbound request.
|
||||
// 2. AgentFrameworkResponseHandler reads context.Isolation.UserIsolationKey via the registered
|
||||
// HostedSessionIsolationKeyProvider and stores it on the session as a HostedSessionContext.
|
||||
// 3. FoundryMemoryProvider's stateInitializer reads HostedSessionContext.UserId and uses it as
|
||||
// the FoundryMemoryProviderScope, partitioning memories per user.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development).
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AGENT_NAME is not set.");
|
||||
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
var embeddingDeployment = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
|
||||
var memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "hosted-memory-sample";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in foundry).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
AIProjectClient projectClient = new(projectEndpoint, credential);
|
||||
|
||||
// FoundryMemoryProvider partitions memories per end user via a built-in HostedFoundryMemoryProviderScopes
|
||||
// helper that reads the platform-injected user isolation key from the HostedSessionContext that the
|
||||
// hosting layer placed on the session.
|
||||
FoundryMemoryProvider memoryProvider = new(
|
||||
projectClient,
|
||||
memoryStoreName,
|
||||
stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
|
||||
|
||||
// Provision the memory store on startup if it does not already exist. EnsureMemoryStoreCreatedAsync
|
||||
// is idempotent. Doing this once at start avoids per-request latency.
|
||||
await memoryProvider.EnsureMemoryStoreCreatedAsync(deployment, embeddingDeployment, "Memory store for the hosted travel-assistant sample.");
|
||||
|
||||
const string AgentInstructions = """
|
||||
You are a friendly travel assistant. When the user shares trip preferences, destinations,
|
||||
travel companions, or constraints, remember them and use them in later turns. Use known
|
||||
memories about the user when responding, and do not invent details.
|
||||
""";
|
||||
|
||||
ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
Name = agentName,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deployment,
|
||||
Instructions = AgentInstructions
|
||||
},
|
||||
AIContextProviders = [memoryProvider]
|
||||
});
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,155 @@
|
||||
# Hosted-MemoryAgent
|
||||
|
||||
A hosted Foundry agent that uses **FoundryMemoryProvider** to remember user-private details across
|
||||
requests and across sessions, scoped per end user via the Foundry platform's isolation keys. The
|
||||
agent plays a friendly travel assistant: tell it about your trip, ask follow-up questions in a new
|
||||
session, and it recalls what it learned about you.
|
||||
|
||||
This sample exists to demonstrate two things together:
|
||||
|
||||
1. How to host an agent that consumes a `Microsoft.Extensions.AI.AIContextProvider` (specifically
|
||||
`FoundryMemoryProvider`) under the Foundry Responses hosting layer.
|
||||
2. How the new `HostedSessionContext` flows from the `Foundry` platform isolation headers
|
||||
(`x-agent-user-isolation-key`, `x-agent-chat-isolation-key`) through the
|
||||
`HostedSessionIsolationKeyProvider` into the provider's `stateInitializer`, so memories are
|
||||
partitioned per user automatically.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with at least one chat model deployment and one embedding model deployment
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your values:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Required:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<account>.services.ai.azure.com/api/projects/<project>
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample
|
||||
AGENT_NAME=hosted-memory-agent
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
```
|
||||
|
||||
For local container runs only (the platform supplies these in production):
|
||||
|
||||
```env
|
||||
HOSTED_USER_ISOLATION_KEY=alice
|
||||
HOSTED_CHAT_ISOLATION_KEY=alice-chat-1
|
||||
```
|
||||
|
||||
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## How memory scoping works
|
||||
|
||||
| Layer | Source of the user identity |
|
||||
|---|---|
|
||||
| Inbound request | The Foundry platform sets `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every request. |
|
||||
| Hosting layer | `AgentFrameworkResponseHandler` resolves a `HostedSessionIsolationKeyProvider` from DI and calls `GetKeysAsync(context, request, ct)`. The default implementation reads `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. |
|
||||
| Session | The handler stores the resolved values on the session as a `HostedSessionContext` on the first request, and validates the values on every subsequent request that resumes the same conversation (mismatch returns 403). |
|
||||
| Memory provider | The sample's `stateInitializer` reads `session.GetHostedContext().UserId` and uses it as the `FoundryMemoryProviderScope`. Memories are partitioned per user. |
|
||||
|
||||
When running outside the Foundry platform the headers are absent. The sample registers
|
||||
`DevTemporaryLocalSessionIsolationKeyProvider` (via `AddDevTemporaryLocalContributorSetup`) which
|
||||
falls back to the `HOSTED_USER_ISOLATION_KEY` and `HOSTED_CHAT_ISOLATION_KEY` environment variables,
|
||||
defaulting to a single `local-dev-*` bucket when neither is set.
|
||||
|
||||
> **Production warning.** Never register `DevTemporaryLocalSessionIsolationKeyProvider` in
|
||||
> production. The Foundry platform sets the isolation keys for every inbound request, and
|
||||
> client-supplied environment variables can be forged.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The agent starts on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.", "model": "hosted-memory-agent"}'
|
||||
```
|
||||
|
||||
Wait a few seconds for memory extraction, then ask a follow-up using the response id from the
|
||||
previous call as `previous_response_id`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "What do you already know about my upcoming trip?", "previous_response_id": "<id>", "model": "hosted-memory-agent"}'
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies
|
||||
outside this folder. Use `Dockerfile.contributor` which takes a pre-published output.
|
||||
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-memory-agent .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
```bash
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-memory-agent \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
-e HOSTED_USER_ISOLATION_KEY=alice \
|
||||
-e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \
|
||||
--env-file .env \
|
||||
hosted-memory-agent
|
||||
```
|
||||
|
||||
### 4. Smoke test the running container
|
||||
|
||||
A scripted smoke test that exercises memory recall and per-user isolation across two simulated
|
||||
users is provided at `scripts/smoke.ps1`. From the sample folder:
|
||||
|
||||
```powershell
|
||||
pwsh ./scripts/smoke.ps1
|
||||
```
|
||||
|
||||
The script publishes the project, builds the image, runs the container with two distinct
|
||||
`HOSTED_USER_ISOLATION_KEY` values, drives a multi-turn conversation per user, asserts that each
|
||||
user only sees their own memories, and exits non-zero on failure.
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the
|
||||
standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in
|
||||
`HostedMemoryAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## How it differs from sibling samples
|
||||
|
||||
| | Hosted-ChatClientAgent | Hosted-MemoryAgent |
|
||||
|---|---|---|
|
||||
| **Agent definition** | Inline (`AsAIAgent(model, instructions)`) | Inline, plus `AIContextProviders = [memoryProvider]` |
|
||||
| **State** | None beyond the conversation history | Per-user memories persisted in Foundry Memory |
|
||||
| **Identity** | Not used | Required: `HostedSessionContext.UserId` flows into the memory scope |
|
||||
| **Local dev** | `AddDevTemporaryLocalContributorSetup()` keeps requests succeeding when isolation headers are absent | Same; additionally honours `HOSTED_USER_ISOLATION_KEY` to simulate distinct users |
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-memory-agent
|
||||
displayName: "Hosted Memory Agent"
|
||||
|
||||
description: >
|
||||
A travel-assistant hosted agent that uses FoundryMemoryProvider to remember user-private
|
||||
preferences and details across sessions. Memory is scoped per end user via the Foundry
|
||||
platform's isolation key headers.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
- Agent Framework
|
||||
- Memory
|
||||
- Foundry Memory
|
||||
|
||||
template:
|
||||
name: hosted-memory-agent
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
@@ -0,0 +1,9 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-memory-agent
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
#requires -Version 7
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Local smoke test for the Hosted-MemoryAgent sample.
|
||||
.DESCRIPTION
|
||||
Publishes the sample, builds the contributor Docker image, runs the container twice with two
|
||||
distinct HOSTED_USER_ISOLATION_KEY values, drives a multi-turn conversation per user via curl
|
||||
invocations, and asserts that each user only sees their own remembered details.
|
||||
Exits non-zero on failure.
|
||||
|
||||
Prerequisites:
|
||||
- Docker
|
||||
- az login (token is fetched from the host)
|
||||
- .env populated with AZURE_AI_PROJECT_ENDPOINT and model deployments
|
||||
.NOTES
|
||||
This script is for local Docker debugging only. The Foundry platform supplies the isolation
|
||||
keys for every inbound request in production and the dev fallback used here must not be
|
||||
enabled in production deployments.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Port = 8088,
|
||||
[string]$ImageName = 'hosted-memory-agent-smoke',
|
||||
[int]$RecallDelaySeconds = 25
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location -Path $PSScriptRoot/..
|
||||
|
||||
if (-not (Test-Path .env)) {
|
||||
throw '.env not found. Copy .env.example to .env and fill in AZURE_AI_PROJECT_ENDPOINT.'
|
||||
}
|
||||
|
||||
Write-Host '==> Publishing sample for linux-musl-x64 ...'
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out --tl:off | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
|
||||
|
||||
Write-Host '==> Building docker image ...'
|
||||
docker build -f Dockerfile.contributor -t $ImageName . | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'docker build failed.' }
|
||||
|
||||
Write-Host '==> Fetching bearer token ...'
|
||||
$bearer = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv
|
||||
if (-not $bearer) { throw 'Failed to obtain bearer token. Run az login.' }
|
||||
|
||||
function Start-Container([string]$UserKey, [string]$ChatKey, [string]$ContainerName) {
|
||||
docker rm -f $ContainerName 2>$null | Out-Null
|
||||
docker run -d --name $ContainerName -p ${Port}:8088 `
|
||||
-e AGENT_NAME=hosted-memory-agent `
|
||||
-e AZURE_BEARER_TOKEN=$bearer `
|
||||
-e HOSTED_USER_ISOLATION_KEY=$UserKey `
|
||||
-e HOSTED_CHAT_ISOLATION_KEY=$ChatKey `
|
||||
--env-file .env `
|
||||
$ImageName | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "docker run failed for $ContainerName." }
|
||||
# Wait briefly for the listener to come up.
|
||||
Start-Sleep -Seconds 6
|
||||
}
|
||||
|
||||
function Invoke-Agent([string]$Prompt, [string]$PreviousResponseId = $null) {
|
||||
$body = @{ input = $Prompt; model = 'hosted-memory-agent' }
|
||||
if ($PreviousResponseId) { $body['previous_response_id'] = $PreviousResponseId }
|
||||
$json = $body | ConvertTo-Json -Compress
|
||||
$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Body $json
|
||||
return $resp
|
||||
}
|
||||
|
||||
function Assert-Contains([string]$Haystack, [string]$Needle, [string]$Label) {
|
||||
if ($Haystack -notmatch [regex]::Escape($Needle)) {
|
||||
throw "FAILED [$Label]: expected response to contain '$Needle' but got: $Haystack"
|
||||
}
|
||||
Write-Host "PASS [$Label]: response contains '$Needle'."
|
||||
}
|
||||
|
||||
function Assert-NotContains([string]$Haystack, [string]$Needle, [string]$Label) {
|
||||
if ($Haystack -match [regex]::Escape($Needle)) {
|
||||
throw "FAILED [$Label]: response unexpectedly contains '$Needle': $Haystack"
|
||||
}
|
||||
Write-Host "PASS [$Label]: response does not contain '$Needle'."
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host '==> Phase 1: alice teaches the agent her trip details ...'
|
||||
Start-Container -UserKey 'alice' -ChatKey 'alice-chat-1' -ContainerName 'hosted-memory-smoke-alice'
|
||||
$r1 = Invoke-Agent -Prompt 'Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.'
|
||||
$r2 = Invoke-Agent -Prompt 'I am travelling with my sister and we love finding scenic viewpoints.' -PreviousResponseId $r1.id
|
||||
|
||||
Write-Host "==> Waiting $RecallDelaySeconds s for memory extraction ..."
|
||||
Start-Sleep -Seconds $RecallDelaySeconds
|
||||
|
||||
$r3 = Invoke-Agent -Prompt 'What do you already know about my upcoming trip?' -PreviousResponseId $r2.id
|
||||
$aliceText = ($r3.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
|
||||
Assert-Contains $aliceText 'Patagonia' 'alice recall: Patagonia'
|
||||
|
||||
docker rm -f hosted-memory-smoke-alice | Out-Null
|
||||
|
||||
Write-Host '==> Phase 2: bob starts a fresh container with a different user isolation key ...'
|
||||
Start-Container -UserKey 'bob' -ChatKey 'bob-chat-1' -ContainerName 'hosted-memory-smoke-bob'
|
||||
$b1 = Invoke-Agent -Prompt 'Hello, what trip am I planning?'
|
||||
$bobText = ($b1.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
|
||||
Assert-NotContains $bobText 'Patagonia' 'bob isolation: no leak of alice memories'
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '==> All smoke assertions passed.'
|
||||
}
|
||||
finally {
|
||||
docker rm -f hosted-memory-smoke-alice 2>$null | Out-Null
|
||||
docker rm -f hosted-memory-smoke-bob 2>$null | Out-Null
|
||||
}
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+2
-36
@@ -10,6 +10,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -60,6 +61,7 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -70,39 +72,3 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -47,6 +48,7 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -97,34 +99,3 @@ static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(st
|
||||
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(GetAccessToken());
|
||||
|
||||
private static AccessToken GetAccessToken()
|
||||
{
|
||||
var token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
if (string.IsNullOrEmpty(token) || token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
@@ -21,6 +21,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -57,6 +58,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register the agent and response handler
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
|
||||
// The toolset name must match a toolset registered in your Foundry project.
|
||||
@@ -75,39 +77,3 @@ if (app.Environment.IsDevelopment())
|
||||
app.Run();
|
||||
|
||||
// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.MaxValue);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -24,6 +24,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
|
||||
+1
-28
@@ -17,9 +17,9 @@
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
@@ -192,30 +192,3 @@ static string GetWeather(
|
||||
var condition = conditions[rng.Next(conditions.Length)];
|
||||
return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h.";
|
||||
}
|
||||
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-36
@@ -9,6 +9,7 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
@@ -49,6 +50,7 @@ AIAgent agent = new WorkflowBuilder(frenchAgent)
|
||||
// Host the workflow agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
@@ -59,39 +61,3 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="HostedSessionIsolationKeyProvider"/> for local Docker debugging only.
|
||||
///
|
||||
/// When the Foundry platform's <c>x-agent-user-isolation-key</c> and
|
||||
/// <c>x-agent-chat-isolation-key</c> 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 <see cref="HostedSessionIsolationKeyProvider"/> returns null. This provider supplies
|
||||
/// fallback values from the <c>HOSTED_USER_ISOLATION_KEY</c> and <c>HOSTED_CHAT_ISOLATION_KEY</c>
|
||||
/// environment variables, defaulting to the constants below when neither is 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 <c>docker run</c> the sample on their laptop and
|
||||
/// drive a few requests end to end.
|
||||
/// </summary>
|
||||
public sealed class DevTemporaryLocalSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment variable that supplies the user isolation key when the platform header is absent.
|
||||
/// </summary>
|
||||
public const string UserIsolationKeyEnvironmentVariable = "HOSTED_USER_ISOLATION_KEY";
|
||||
|
||||
/// <summary>
|
||||
/// Environment variable that supplies the chat isolation key when the platform header is absent.
|
||||
/// </summary>
|
||||
public const string ChatIsolationKeyEnvironmentVariable = "HOSTED_CHAT_ISOLATION_KEY";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const string DefaultLocalUserIsolationKey = "local-dev-user";
|
||||
|
||||
/// <summary>
|
||||
/// Default chat isolation key used when neither the platform header nor the environment variable
|
||||
/// supplies a value.
|
||||
/// </summary>
|
||||
public const string DefaultLocalChatIsolationKey = "local-dev-chat";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userKey = !string.IsNullOrWhiteSpace(context?.Isolation?.UserIsolationKey)
|
||||
? context!.Isolation!.UserIsolationKey
|
||||
: 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<HostedSessionContext?>(new HostedSessionContext(userKey!, chatKey!));
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production. Tokens expire (around one hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
public sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DevTemporaryTokenCredential"/> class.
|
||||
/// Reads the bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable when present.
|
||||
/// </summary>
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(this.GetAccessToken());
|
||||
}
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Registration helpers for the developer-only utilities shipped in this sample-shared project.
|
||||
/// </summary>
|
||||
public static class HostedContributorSetupExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers developer-only services that allow a hosted Foundry agent to run outside the
|
||||
/// Foundry platform (e.g., inside a Docker container during contributor debugging).
|
||||
///
|
||||
/// <para><b>For local Docker debugging only and should not be used in production.</b></para>
|
||||
///
|
||||
/// Currently this method registers a <see cref="DevTemporaryLocalSessionIsolationKeyProvider"/>
|
||||
/// so that requests succeed when the platform's <c>x-agent-user-isolation-key</c> and
|
||||
/// <c>x-agent-chat-isolation-key</c> headers are absent. In production those headers are
|
||||
/// always present and the default platform isolation key provider (registered automatically by
|
||||
/// the hosting layer) is used instead.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register the developer-only services into.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddDevTemporaryLocalContributorSetup(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider, DevTemporaryLocalSessionIsolationKeyProvider>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>Hosted_Shared_Contributor_Setup</RootNamespace>
|
||||
<AssemblyName>Hosted_Shared_Contributor_Setup</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -26,6 +26,12 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
|
||||
private readonly FoundryToolboxService? _toolboxService;
|
||||
|
||||
/// <summary>
|
||||
/// Cached fallback used when no <see cref="HostedSessionIsolationKeyProvider"/> is registered in DI.
|
||||
/// Avoids a per-request allocation on the request hot path.
|
||||
/// </summary>
|
||||
private static readonly HostedSessionIsolationKeyProvider s_defaultIsolationKeyProvider = new PlatformHostedSessionIsolationKeyProvider();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
|
||||
/// that resolves agents from keyed DI services.
|
||||
@@ -67,6 +73,42 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
|
||||
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 2.5. Resolve and apply the per-request hosted session identity context.
|
||||
// Fresh sessions are tagged once. Resumed sessions are validated against the live request
|
||||
// to detect cross-user session leaks and in-process tampering of the persisted identity.
|
||||
var isolationKeyProvider = this._serviceProvider.GetService<HostedSessionIsolationKeyProvider>()
|
||||
?? s_defaultIsolationKeyProvider;
|
||||
var resolvedHostedContext = await isolationKeyProvider.GetKeysAsync(context, request, cancellationToken).ConfigureAwait(false);
|
||||
if (resolvedHostedContext is null)
|
||||
{
|
||||
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, " +
|
||||
"or register a custom provider that supplies fallback values for local development.");
|
||||
}
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
var existingHostedContext = session.GetHostedContext();
|
||||
if (existingHostedContext is null)
|
||||
{
|
||||
// Fresh path: the session has no hosted context yet (either freshly created here,
|
||||
// or freshly loaded for a conversation_id that the platform supplied without any
|
||||
// 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))
|
||||
{
|
||||
// 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
|
||||
// persisted identity. Reject the request hard.
|
||||
throw new ResponsesApiException(
|
||||
new Error("hosted_session_identity_mismatch", "Hosted session identity context mismatch"),
|
||||
403);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create the SDK event stream builder
|
||||
var stream = new ResponseEventStream(context, request);
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in <see cref="FoundryMemoryProvider"/> <c>stateInitializer</c> factories that derive the
|
||||
/// <see cref="FoundryMemoryProviderScope"/> from the per-session <see cref="HostedSessionContext"/>
|
||||
/// applied by the Foundry hosting layer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pass the result of any of these helpers as the <c>stateInitializer</c> argument when constructing
|
||||
/// <see cref="FoundryMemoryProvider"/>:
|
||||
/// <code>
|
||||
/// new FoundryMemoryProvider(client, "my-store",
|
||||
/// stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
|
||||
/// </code>
|
||||
/// All helpers throw <see cref="InvalidOperationException"/> when
|
||||
/// <see cref="HostedSessionContextExtensions.GetHostedContext"/> returns <see langword="null"/>.
|
||||
/// That happens when the agent runs outside the Foundry hosting layer (e.g., a console app); in
|
||||
/// that case write a custom <c>stateInitializer</c> instead of using these helpers.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class HostedFoundryMemoryProviderScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per end user, using
|
||||
/// <see cref="HostedSessionContext.UserId"/> as the partition key.
|
||||
/// </summary>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUser() =>
|
||||
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).UserId));
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per conversation, using
|
||||
/// <see cref="HostedSessionContext.ChatId"/> as the partition key. Use this when memories should
|
||||
/// be visible to every participant in a shared conversation (for example, a Teams group chat).
|
||||
/// </summary>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerChat() =>
|
||||
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId));
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, using
|
||||
/// <c>"{UserId}:{ChatId}"</c> as the partition key. Use this when memories should be visible
|
||||
/// only to the same user within the same conversation.
|
||||
/// </summary>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUserAndChat() =>
|
||||
session =>
|
||||
{
|
||||
var ctx = GetRequiredHostedContext(session);
|
||||
return new FoundryMemoryProvider.State(new FoundryMemoryProviderScope($"{ctx.UserId}:{ctx.ChatId}"));
|
||||
};
|
||||
|
||||
private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) =>
|
||||
session?.GetHostedContext()
|
||||
?? throw new InvalidOperationException(
|
||||
$"{nameof(HostedSessionContext)} was not provided by the hosting layer. " +
|
||||
$"The {nameof(HostedFoundryMemoryProviderScopes)} helpers require the agent to be hosted via the Foundry hosting layer. " +
|
||||
"If running outside a hosted Foundry container, supply a custom stateInitializer to FoundryMemoryProvider instead.");
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency-injection helpers that register a <see cref="FoundryMemoryProvider"/> wired with a
|
||||
/// <see cref="HostedFoundryMemoryProviderScopes"/> strategy.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class HostedFoundryMemoryProviderServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a singleton <see cref="FoundryMemoryProvider"/> wired to the supplied
|
||||
/// <see cref="AIProjectClient"/> and the supplied <paramref name="stateInitializer"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="client">The <see cref="AIProjectClient"/> used to talk to Foundry Memory.</param>
|
||||
/// <param name="memoryStoreName">The name of the memory store in Microsoft Foundry.</param>
|
||||
/// <param name="stateInitializer">
|
||||
/// Strategy that selects the per-session <see cref="FoundryMemoryProviderScope"/>. When
|
||||
/// <see langword="null"/>, the extension uses <see cref="HostedFoundryMemoryProviderScopes.PerUser"/>.
|
||||
/// Pass any other helper (or a custom delegate) to override.
|
||||
/// </param>
|
||||
/// <param name="options">Optional <see cref="FoundryMemoryProviderOptions"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddHostedFoundryMemoryProvider(
|
||||
this IServiceCollection services,
|
||||
AIProjectClient client,
|
||||
string memoryStoreName,
|
||||
Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null,
|
||||
FoundryMemoryProviderOptions? options = null)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNull(client);
|
||||
Throw.IfNullOrWhitespace(memoryStoreName);
|
||||
|
||||
var initializer = stateInitializer ?? HostedFoundryMemoryProviderScopes.PerUser();
|
||||
services.AddSingleton(sp => new FoundryMemoryProvider(
|
||||
client,
|
||||
memoryStoreName,
|
||||
initializer,
|
||||
options,
|
||||
sp.GetService<ILoggerFactory>()));
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a singleton <see cref="FoundryMemoryProvider"/> that resolves its
|
||||
/// <see cref="AIProjectClient"/> from <see cref="IServiceProvider"/> at construction time.
|
||||
/// Use this overload when an <see cref="AIProjectClient"/> is already registered with the
|
||||
/// service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="memoryStoreName">The name of the memory store in Microsoft Foundry.</param>
|
||||
/// <param name="stateInitializer">
|
||||
/// Strategy that selects the per-session <see cref="FoundryMemoryProviderScope"/>. When
|
||||
/// <see langword="null"/>, the extension uses <see cref="HostedFoundryMemoryProviderScopes.PerUser"/>.
|
||||
/// Pass any other helper (or a custom delegate) to override.
|
||||
/// </param>
|
||||
/// <param name="options">Optional <see cref="FoundryMemoryProviderOptions"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddHostedFoundryMemoryProvider(
|
||||
this IServiceCollection services,
|
||||
string memoryStoreName,
|
||||
Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null,
|
||||
FoundryMemoryProviderOptions? options = null)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrWhitespace(memoryStoreName);
|
||||
|
||||
var initializer = stateInitializer ?? HostedFoundryMemoryProviderScopes.PerUser();
|
||||
services.AddSingleton(sp => new FoundryMemoryProvider(
|
||||
sp.GetRequiredService<AIProjectClient>(),
|
||||
memoryStoreName,
|
||||
initializer,
|
||||
options,
|
||||
sp.GetService<ILoggerFactory>()));
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the per-session identity values produced by a <see cref="HostedSessionIsolationKeyProvider"/>
|
||||
/// when a Foundry hosted agent processes a request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="UserId"/> partitions data that belongs to the individual who initiated the request
|
||||
/// (e.g., personal memory, per-user preferences). The <see cref="ChatId"/> 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 <see cref="HostedSessionIsolationKeyProvider"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Instances are constructed by the hosting layer from the platform-provided
|
||||
/// <c>IsolationContext</c> headers and stored on the session via
|
||||
/// <see cref="HostedSessionContextExtensions.SetHostedContext"/>. Consumers (typically
|
||||
/// <see cref="AIContextProvider"/> implementations) read the values through
|
||||
/// <see cref="HostedSessionContextExtensions.GetHostedContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class HostedSessionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HostedSessionContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userId">The opaque user identity for this hosted session. Must not be null or whitespace.</param>
|
||||
/// <param name="chatId">The opaque chat (conversation) identity for this hosted session. Must not be null or whitespace.</param>
|
||||
/// <exception cref="System.ArgumentException">Thrown when <paramref name="userId"/> or <paramref name="chatId"/> is null or whitespace.</exception>
|
||||
public HostedSessionContext(string userId, string chatId)
|
||||
{
|
||||
this.UserId = Throw.IfNullOrWhitespace(userId);
|
||||
this.ChatId = Throw.IfNullOrWhitespace(chatId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the opaque user identity for this hosted session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stable for a given user across sessions. In production this is sourced from the
|
||||
/// <c>x-agent-user-isolation-key</c> platform header.
|
||||
/// </remarks>
|
||||
public string UserId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the opaque chat (conversation) identity for this hosted session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In a 1:1 user-to-agent chat this typically equals <see cref="UserId"/>. 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 <c>x-agent-chat-isolation-key</c> platform header.
|
||||
/// </remarks>
|
||||
public string ChatId { get; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for reading and writing the <see cref="HostedSessionContext"/> associated
|
||||
/// with an <see cref="AgentSession"/> in a Foundry hosted agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The hosted session context is written exactly once by the hosting layer when a session is created,
|
||||
/// and is validated against the live request on every subsequent invocation. The <see cref="SetHostedContext"/>
|
||||
/// method is intentionally <see langword="internal"/> so that only the hosting layer can establish the
|
||||
/// identity values; consumers (such as <see cref="AIContextProvider"/> implementations) read the values
|
||||
/// through the public <see cref="GetHostedContext"/> accessor.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class HostedSessionContextExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The well-known <see cref="AgentSessionStateBag"/> key used to store the
|
||||
/// <see cref="HostedSessionContext"/> on a session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exposed as a constant so consumers can correlate persisted state across processes.
|
||||
/// External code must not write to this key directly; use <see cref="SetHostedContext"/> from the
|
||||
/// hosting assembly instead.
|
||||
/// </remarks>
|
||||
public const string StateKey = "Microsoft.Agents.AI.Foundry.Hosting.HostedSessionContext";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="HostedSessionContext"/> previously written by the hosting layer
|
||||
/// for this session, if any.
|
||||
/// </summary>
|
||||
/// <param name="session">The session to read from.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="HostedSessionContext"/> for the session, or <see langword="null"/> when the
|
||||
/// session was not produced by a hosted agent (or the value has not yet been written).
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="session"/> is <see langword="null"/>.</exception>
|
||||
public static HostedSessionContext? GetHostedContext(this AgentSession session)
|
||||
{
|
||||
Throw.IfNull(session);
|
||||
|
||||
return session.StateBag.TryGetValue<HostedSessionContext>(StateKey, out var context, HostedSessionJsonUtilities.DefaultOptions)
|
||||
? context
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the <see cref="HostedSessionContext"/> for this session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session to write to.</param>
|
||||
/// <param name="context">The hosted session context to associate with <paramref name="session"/>.</param>
|
||||
/// <remarks>
|
||||
/// Internal to the hosting assembly. Consumers must not invoke this method directly; the hosting
|
||||
/// layer is the single writer and uses validation against the live request to detect any tampering
|
||||
/// that does occur via lower-level APIs. Throws when a context has already been written for this
|
||||
/// session to enforce the write-once contract.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="session"/> or <paramref name="context"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when this session already carries a <see cref="HostedSessionContext"/>.</exception>
|
||||
internal static void SetHostedContext(this AgentSession session, HostedSessionContext context)
|
||||
{
|
||||
Throw.IfNull(session);
|
||||
Throw.IfNull(context);
|
||||
|
||||
if (session.StateBag.TryGetValue<HostedSessionContext>(StateKey, out _, HostedSessionJsonUtilities.DefaultOptions))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A {nameof(HostedSessionContext)} has already been written to this session. " +
|
||||
"The hosted session identity is write-once; resumed sessions must validate against the existing context, not overwrite it.");
|
||||
}
|
||||
|
||||
session.StateBag.SetValue(StateKey, context, HostedSessionJsonUtilities.DefaultOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the per-request <see cref="HostedSessionContext"/> for a Foundry hosted agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementations are invoked once per incoming Responses API request. The returned
|
||||
/// <see cref="HostedSessionContext"/> establishes the identity of a freshly created session and
|
||||
/// is validated against the live request on every subsequent invocation that resumes the same session.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default implementation registered when no custom <see cref="HostedSessionIsolationKeyProvider"/>
|
||||
/// is present in DI maps the platform-injected <c>x-agent-user-isolation-key</c> and
|
||||
/// <c>x-agent-chat-isolation-key</c> headers via <see cref="ResponseContext.Isolation"/>. 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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Implementations must return a <see cref="HostedSessionContext"/> whose <see cref="HostedSessionContext.UserId"/>
|
||||
/// and <see cref="HostedSessionContext.ChatId"/> are both non-null and non-whitespace. Returning either as null
|
||||
/// (or throwing from <see cref="GetKeysAsync"/>) is treated as a configuration error and surfaces as a
|
||||
/// 500 from the hosting layer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public abstract class HostedSessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the <see cref="HostedSessionContext"/> for the supplied request.
|
||||
/// </summary>
|
||||
/// <param name="context">The per-request <see cref="ResponseContext"/> from the Azure AI Responses Server SDK.</param>
|
||||
/// <param name="request">The <see cref="CreateResponse"/> describing the incoming request.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="HostedSessionContext"/> with non-null <see cref="HostedSessionContext.UserId"/> and
|
||||
/// <see cref="HostedSessionContext.ChatId"/>, or <see langword="null"/> when the implementation cannot
|
||||
/// produce identity keys for the current request. A <see langword="null"/> result is treated as a
|
||||
/// configuration error by the hosting layer and surfaces as 500.
|
||||
/// </returns>
|
||||
public abstract ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization utilities for hosted session identity types.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal static class HostedSessionJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Default JSON serializer options for hosted session state.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
TypeInfoResolver = HostedSessionJsonContext.Default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON serialization context for hosted session identity types.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.General,
|
||||
UseStringEnumConverter = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(HostedSessionContext))]
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal partial class HostedSessionJsonContext : JsonSerializerContext;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="HostedSessionIsolationKeyProvider"/> implementation that maps the platform-injected
|
||||
/// <c>x-agent-user-isolation-key</c> and <c>x-agent-chat-isolation-key</c> headers from
|
||||
/// <see cref="ResponseContext.Isolation"/> into a <see cref="HostedSessionContext"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the implementation used in production Foundry hosted environments. When running locally
|
||||
/// outside the platform, both isolation keys are <see langword="null"/>, which causes
|
||||
/// <see cref="GetKeysAsync"/> to return <see langword="null"/>. 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 <see cref="HostedSessionIsolationKeyProvider"/> implementation
|
||||
/// that provides fallback values for the missing headers.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal sealed class PlatformHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userKey = context?.Isolation?.UserIsolationKey;
|
||||
var chatKey = context?.Isolation?.ChatIsolationKey;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userKey) || string.IsNullOrWhiteSpace(chatKey))
|
||||
{
|
||||
return new ValueTask<HostedSessionContext?>((HostedSessionContext?)null);
|
||||
}
|
||||
|
||||
return new ValueTask<HostedSessionContext?>(new HostedSessionContext(userKey!, chatKey!));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using Azure.Identity;
|
||||
using Azure.Search.Documents;
|
||||
using Azure.Search.Documents.Models;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -34,6 +35,7 @@ AIAgent agent = scenario switch
|
||||
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
|
||||
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
|
||||
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
|
||||
"memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false),
|
||||
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
|
||||
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
|
||||
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
|
||||
@@ -179,6 +181,34 @@ static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment
|
||||
AIFunctionFactory.Create(ReadFile)
|
||||
]);
|
||||
|
||||
// Memory scenario. The agent uses FoundryMemoryProvider scoped per user via the
|
||||
// HostedSessionContext that the hosting layer applies from the platform isolation headers.
|
||||
// In production the platform sets the headers; here we rely on the default
|
||||
// PlatformHostedSessionIsolationKeyProvider that AgentFrameworkResponseHandler resolves.
|
||||
static async Task<AIAgent> CreateMemoryAgentAsync(AIProjectClient client, string deployment)
|
||||
{
|
||||
var embedding = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
|
||||
var memoryStoreName = Environment.GetEnvironmentVariable("IT_MEMORY_STORE_ID") ?? "it-memory-store";
|
||||
|
||||
var memoryProvider = new FoundryMemoryProvider(
|
||||
client,
|
||||
memoryStoreName,
|
||||
stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
|
||||
|
||||
await memoryProvider.EnsureMemoryStoreCreatedAsync(deployment, embedding, "Memory store for hosted-memory IT scenario.").ConfigureAwait(false);
|
||||
|
||||
return client.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "memory-agent",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deployment,
|
||||
Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details."
|
||||
},
|
||||
AIContextProviders = [memoryProvider]
|
||||
});
|
||||
}
|
||||
|
||||
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
|
||||
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=memory</c> mode.
|
||||
/// Used by tests that exercise <see cref="Microsoft.Agents.AI.Foundry.FoundryMemoryProvider"/>
|
||||
/// running inside the Foundry hosted agent. The memory store name is randomised per fixture
|
||||
/// instance so concurrent test runs do not share state.
|
||||
/// </summary>
|
||||
public sealed class MemoryHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "memory";
|
||||
|
||||
/// <summary>
|
||||
/// Memory store name passed to the test container via <c>IT_MEMORY_STORE_ID</c> so that each
|
||||
/// fixture instance gets a fresh, isolated bucket of memories.
|
||||
/// </summary>
|
||||
public string MemoryStoreId { get; } = $"it-memory-{Guid.NewGuid():N}";
|
||||
|
||||
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
|
||||
{
|
||||
environment["IT_MEMORY_STORE_ID"] = this.MemoryStoreId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the Hosted-MemoryAgent end-to-end against a deployed test container running the
|
||||
/// <c>IT_SCENARIO=memory</c> scenario. Asserts that <see cref="Microsoft.Agents.AI.Foundry.FoundryMemoryProvider"/>
|
||||
/// scoped via <see cref="Microsoft.Agents.AI.Foundry.Hosting.HostedSessionContext"/> recalls user
|
||||
/// preferences across multiple turns of a conversation.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class MemoryHostedAgentTests(MemoryHostedAgentFixture fixture) : IClassFixture<MemoryHostedAgentFixture>
|
||||
{
|
||||
private readonly MemoryHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public async Task Memory_RecallsAcrossTurnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act: teach the agent two pieces of information about the user.
|
||||
var first = await agent.RunAsync("My name is Taylor and I am planning a hiking trip to Patagonia in November.", session);
|
||||
Assert.False(string.IsNullOrWhiteSpace(first.Text));
|
||||
|
||||
var second = await agent.RunAsync("I am travelling with my sister and we love finding scenic viewpoints.", session);
|
||||
Assert.False(string.IsNullOrWhiteSpace(second.Text));
|
||||
|
||||
// FoundryMemoryProvider defaults to UpdateDelay=0 (immediate trigger). Server-side ingestion
|
||||
// typically completes within ~3 seconds; allow a small margin.
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
|
||||
var recall = await agent.RunAsync("What do you already know about my upcoming trip?", session);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Patagonia", recall.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Foundry Memory write propagation is eventually consistent and the in-container WhenUpdatesCompletedAsync flush hook is not callable from the test process; this scenario is exercised manually via the sample's smoke.ps1.")]
|
||||
public async Task Memory_PersistsAcrossSessionsForSameUserAsync()
|
||||
{
|
||||
// Arrange: drive a session that establishes some user-private memory. Foundry Memory
|
||||
// extracts memories more reliably from multi-turn conversations than from a single
|
||||
// imperative utterance, so mirror the sample's two-turn teaching pattern.
|
||||
var agent = this._fixture.Agent;
|
||||
var teachingSession = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync("My preferred airline is Iberia and I always fly business class.", teachingSession);
|
||||
await agent.RunAsync("I also prefer aisle seats whenever they are available.", teachingSession);
|
||||
|
||||
// FoundryMemoryProvider defaults to UpdateDelay=0 (immediate trigger). Server-side
|
||||
// ingestion typically completes within ~3 seconds; poll a fresh-session recall a few
|
||||
// times before failing so the test does not flake on cold caches.
|
||||
AgentResponse recall = null!;
|
||||
const int MaxAttempts = 6;
|
||||
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
|
||||
var freshSession = await agent.CreateSessionAsync();
|
||||
recall = await agent.RunAsync("Which airline do I prefer? Reply with just the airline name.", freshSession);
|
||||
|
||||
if (recall.Text.Contains("Iberia", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Iberia", recall.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ $Scenarios = @(
|
||||
'tool-calling-approval',
|
||||
'mcp-toolbox',
|
||||
'custom-storage',
|
||||
'memory',
|
||||
'azure-search-rag',
|
||||
'session-files'
|
||||
)
|
||||
|
||||
+4
@@ -47,6 +47,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -75,6 +76,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("keyed-agent", agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -116,6 +118,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(preWrapped);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -147,6 +150,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
+17
@@ -29,6 +29,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -67,6 +68,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("my-agent", agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -102,6 +104,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -151,6 +154,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("my-agent", agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -188,6 +192,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("entity-agent", agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -228,6 +233,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -263,6 +269,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -297,6 +304,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -331,6 +339,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -376,6 +385,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -424,6 +434,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -467,6 +478,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -506,6 +518,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -548,6 +561,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -589,6 +603,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton<AIAgent>("agent-1", agent1);
|
||||
services.AddKeyedSingleton<AIAgent>("agent-2", agent2);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -626,6 +641,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -663,6 +679,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
+2
@@ -144,6 +144,7 @@ public class AgentFrameworkResponseHandlerWorkflowTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton("my-workflow", workflowAgent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
@@ -166,6 +167,7 @@ public class AgentFrameworkResponseHandlerWorkflowTests
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(agent);
|
||||
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Test fake that returns a non-null <see cref="HostedSessionContext"/> by default, allowing tests
|
||||
/// that were written before the strict isolation-key contract to keep passing without each test
|
||||
/// having to stub <c>ResponseContext.Isolation</c>. The constructor also accepts <see langword="null"/>
|
||||
/// values so individual tests can exercise the handler's null-key error path.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
this._context = userId is null || chatId is null
|
||||
? null
|
||||
: new HostedSessionContext(userId, chatId);
|
||||
}
|
||||
|
||||
public override ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken)
|
||||
=> new(this._context);
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HostedFoundryMemoryProviderScopes"/> built-in stateInitializer factories.
|
||||
/// </summary>
|
||||
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 initializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
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_ComposesUserAndChatWithColon()
|
||||
{
|
||||
// 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 PerUser_NullSession_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => initializer(null));
|
||||
Assert.Contains(nameof(HostedSessionContext), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerChat_NullSession_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerChat();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => initializer(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_NullSession_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => initializer(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUser_SessionWithoutHostedContext_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var session = new BareAgentSession();
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => initializer(session));
|
||||
Assert.Contains(nameof(HostedFoundryMemoryProviderScopes), ex.Message);
|
||||
}
|
||||
|
||||
private static BareAgentSession CreateTaggedSession(string userId, string chatId)
|
||||
{
|
||||
var session = new BareAgentSession();
|
||||
session.SetHostedContext(new HostedSessionContext(userId, chatId));
|
||||
return session;
|
||||
}
|
||||
|
||||
private sealed class BareAgentSession : AgentSession
|
||||
{
|
||||
public BareAgentSession() : base(new AgentSessionStateBag()) { }
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HostedFoundryMemoryProviderServiceCollectionExtensions"/>.
|
||||
/// </summary>
|
||||
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]
|
||||
public void AddHostedFoundryMemoryProvider_ExplicitClient_RegistersSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var client = CreateClient();
|
||||
|
||||
// Act
|
||||
services.AddHostedFoundryMemoryProvider(client, MemoryStoreName);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var first = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
var second = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_DiResolvedClient_RegistersSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(CreateClient());
|
||||
|
||||
// Act
|
||||
services.AddHostedFoundryMemoryProvider(MemoryStoreName);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var first = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
var second = sp.GetRequiredService<FoundryMemoryProvider>();
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_DiResolvedClient_MissingClient_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act
|
||||
services.AddHostedFoundryMemoryProvider(MemoryStoreName);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
Assert.Throws<InvalidOperationException>(() => sp.GetRequiredService<FoundryMemoryProvider>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_NullStateInitializer_DefaultsToPerUser()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession();
|
||||
|
||||
// Act
|
||||
var services = new ServiceCollection();
|
||||
services.AddHostedFoundryMemoryProvider(CreateClient(), MemoryStoreName);
|
||||
var provider = services.BuildServiceProvider().GetRequiredService<FoundryMemoryProvider>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
var defaultInitializer = HostedFoundryMemoryProviderScopes.PerUser();
|
||||
var state = defaultInitializer(session);
|
||||
Assert.Equal(TestUserId, state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHostedFoundryMemoryProvider_CustomStateInitializer_IsHonored()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession();
|
||||
static FoundryMemoryProvider.State Custom(AgentSession? _)
|
||||
=> new(new FoundryMemoryProviderScope("custom-scope"));
|
||||
|
||||
// Act
|
||||
var services = new ServiceCollection();
|
||||
services.AddHostedFoundryMemoryProvider(CreateClient(), MemoryStoreName, Custom);
|
||||
var provider = services.BuildServiceProvider().GetRequiredService<FoundryMemoryProvider>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
var state = Custom(session);
|
||||
Assert.Equal("custom-scope", state.Scope.Scope);
|
||||
}
|
||||
|
||||
private static AIProjectClient CreateClient()
|
||||
=> new(new Uri("https://example.services.ai.azure.com/api/projects/test"), new DefaultAzureCredential());
|
||||
|
||||
private static BareAgentSession CreateTaggedSession()
|
||||
{
|
||||
var session = new BareAgentSession();
|
||||
session.SetHostedContext(new HostedSessionContext(TestUserId, TestChatId));
|
||||
return session;
|
||||
}
|
||||
|
||||
private sealed class BareAgentSession : AgentSession
|
||||
{
|
||||
public BareAgentSession() : base(new AgentSessionStateBag()) { }
|
||||
}
|
||||
}
|
||||
+1
@@ -101,6 +101,7 @@ public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
||||
AIAgent agent = new ChatClientAgent(chatClient);
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
builder.Services.AddLogging();
|
||||
|
||||
this._app = builder.Build();
|
||||
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests covering the per-session identity context that <see cref="AgentFrameworkResponseHandler"/>
|
||||
/// applies via the registered <see cref="HostedSessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
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<ArgumentNullException>(() => new HostedSessionContext(null!, TestChatId));
|
||||
Assert.Throws<ArgumentNullException>(() => new HostedSessionContext(TestUserId, null!));
|
||||
Assert.Throws<ArgumentException>(() => new HostedSessionContext(string.Empty, TestChatId));
|
||||
Assert.Throws<ArgumentException>(() => new HostedSessionContext(TestUserId, " "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformProvider_MapsIsolationContextValuesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PlatformHostedSessionIsolationKeyProvider();
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.Isolation).Returns(new IsolationContext(TestUserId, TestChatId));
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
|
||||
// Act
|
||||
var result = await provider.GetKeysAsync(mockContext.Object, request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(TestUserId, result.UserId);
|
||||
Assert.Equal(TestChatId, result.ChatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformProvider_ReturnsNullWhenIsolationKeysAreEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PlatformHostedSessionIsolationKeyProvider();
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
// CallBase delegates to ResponseContext.Isolation default which is IsolationContext.Empty.
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
|
||||
// Act
|
||||
var result = await provider.GetKeysAsync(mockContext.Object, request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_FreshSession_AppliesContextFromCustomProviderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider);
|
||||
|
||||
var (request, mockContext) = BuildFreshRequest();
|
||||
|
||||
// Act
|
||||
await DrainAsync(handler.CreateAsync(request, mockContext.Object, CancellationToken.None));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
var ctx = capturingAgent.LastSession.GetHostedContext();
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
Assert.Equal("chat-A", ctx.ChatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_NullKeysFromProvider_ThrowsInvalidOperationAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider(userId: null, chatId: null);
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider);
|
||||
|
||||
var (request, mockContext) = BuildFreshRequest();
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => DrainAsync(handler.CreateAsync(request, mockContext.Object, CancellationToken.None)));
|
||||
Assert.Contains(nameof(HostedSessionIsolationKeyProvider), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_ResumeSession_MatchingKeys_PassesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
|
||||
var sessionStore = new InMemoryAgentSessionStore();
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore);
|
||||
|
||||
// Step 1: drive a fresh request to populate the session store with a tagged session.
|
||||
var (freshRequest, freshContext) = BuildFreshRequest();
|
||||
await DrainAsync(handler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None));
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
|
||||
// Step 2: persist the session under a known conversation id (mimics what the handler does
|
||||
// when it has a conversation id; here we plant it directly so we can drive a resume request).
|
||||
const string ConversationId = "resume-chat-id";
|
||||
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession, CancellationToken.None);
|
||||
|
||||
// Step 3: drive a resume request with the same isolation keys.
|
||||
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
|
||||
capturingAgent.LastSession = null;
|
||||
|
||||
// Act
|
||||
await DrainAsync(handler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
var ctx = capturingAgent.LastSession.GetHostedContext();
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_ResumeSession_MismatchedUserId_Returns403Async()
|
||||
{
|
||||
// Arrange
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var aliceProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
|
||||
var sessionStore = new InMemoryAgentSessionStore();
|
||||
var aliceHandler = BuildHandler(capturingAgent, aliceProvider, sessionStore);
|
||||
|
||||
var (freshRequest, freshContext) = BuildFreshRequest();
|
||||
await DrainAsync(aliceHandler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None));
|
||||
const string ConversationId = "resume-chat-id";
|
||||
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, CancellationToken.None);
|
||||
|
||||
// Bob attempts to resume Alice's conversation.
|
||||
var bobProvider = new FakeHostedSessionIsolationKeyProvider("bob", "chat-A");
|
||||
var bobHandler = BuildHandler(capturingAgent, bobProvider, sessionStore);
|
||||
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<ResponsesApiException>(() => DrainAsync(bobHandler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None)));
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
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<ResponsesApiException>(() => DrainAsync(chatBHandler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None)));
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handler_ResumeSession_WithoutPriorContext_StampsAsFreshAsync()
|
||||
{
|
||||
// Arrange: store an untagged session. This case arises in production when the platform
|
||||
// (or the caller) creates a Foundry conversation_id externally, and the very first
|
||||
// hosted-agent request for that conversation hits the handler before any context is
|
||||
// stamped. Such a session is treated as "fresh" rather than "resume" because there is
|
||||
// no prior identity to defend; the stamp made now is what future resumes will validate.
|
||||
var capturingAgent = new HostedContextCapturingAgent();
|
||||
var sessionStore = new InMemoryAgentSessionStore();
|
||||
const string ConversationId = "untagged-chat-id";
|
||||
var untagged = await capturingAgent.CreateSessionAsync(CancellationToken.None);
|
||||
await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, untagged, CancellationToken.None);
|
||||
|
||||
var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice", "chat-A");
|
||||
var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore);
|
||||
var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId);
|
||||
|
||||
// Act
|
||||
await DrainAsync(handler.CreateAsync(resumeRequest, resumeContext.Object, CancellationToken.None));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturingAgent.LastSession);
|
||||
var ctx = capturingAgent.LastSession.GetHostedContext();
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
Assert.Equal("chat-A", ctx.ChatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostedContext_ReturnsNullWhenAbsent()
|
||||
{
|
||||
// Arrange
|
||||
var session = new HostedContextCapturingSession();
|
||||
|
||||
// Act
|
||||
var ctx = session.GetHostedContext();
|
||||
|
||||
// Assert
|
||||
Assert.Null(ctx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHostedContext_ThenGet_RoundTrips()
|
||||
{
|
||||
// Arrange
|
||||
var session = new HostedContextCapturingSession();
|
||||
|
||||
// Act
|
||||
session.SetHostedContext(new HostedSessionContext("alice", "chat-A"));
|
||||
var ctx = session.GetHostedContext();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("alice", ctx.UserId);
|
||||
Assert.Equal("chat-A", ctx.ChatId);
|
||||
}
|
||||
|
||||
private static AgentFrameworkResponseHandler BuildHandler(
|
||||
AIAgent agent,
|
||||
HostedSessionIsolationKeyProvider provider,
|
||||
AgentSessionStore? sessionStore = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(sessionStore ?? new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(agent);
|
||||
services.AddSingleton(provider);
|
||||
var sp = services.BuildServiceProvider();
|
||||
return new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
}
|
||||
|
||||
private static (CreateResponse Request, Mock<ResponseContext> Context) BuildFreshRequest()
|
||||
{
|
||||
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 mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Item>());
|
||||
return (request, mockContext);
|
||||
}
|
||||
|
||||
private static (CreateResponse Request, Mock<ResponseContext> Context) BuildResumeRequest(string conversationId)
|
||||
{
|
||||
var (request, mockContext) = BuildFreshRequest();
|
||||
request.Conversation = BinaryData.FromString($"\"{conversationId}\"");
|
||||
return (request, mockContext);
|
||||
}
|
||||
|
||||
private static async Task DrainAsync(IAsyncEnumerable<ResponseStreamEvent> stream)
|
||||
{
|
||||
await foreach (var _ in stream)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="AIAgent"/> subclass that captures the session it was invoked with so tests
|
||||
/// can inspect the <see cref="HostedSessionContext"/> applied by the handler.
|
||||
/// </summary>
|
||||
private sealed class HostedContextCapturingAgent : AIAgent
|
||||
{
|
||||
public AgentSession? LastSession { get; set; }
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.LastSession = session;
|
||||
return ToAsyncEnumerableAsync(new AgentResponseUpdate
|
||||
{
|
||||
MessageId = "resp_msg_1",
|
||||
Contents = [new Extensions.AI.TextContent("ok")]
|
||||
});
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(new HostedContextCapturingSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(((HostedContextCapturingSession)session).Serialize());
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(HostedContextCapturingSession.Deserialize(serializedState));
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(params AgentResponseUpdate[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal session implementation that round-trips its <see cref="AgentSessionStateBag"/> via JSON.
|
||||
/// </summary>
|
||||
private sealed class HostedContextCapturingSession : AgentSession
|
||||
{
|
||||
public HostedContextCapturingSession()
|
||||
{
|
||||
}
|
||||
|
||||
private HostedContextCapturingSession(AgentSessionStateBag bag)
|
||||
{
|
||||
this.StateBag = bag;
|
||||
}
|
||||
|
||||
public JsonElement Serialize() => this.StateBag.Serialize();
|
||||
|
||||
public static HostedContextCapturingSession Deserialize(JsonElement element)
|
||||
=> new(AgentSessionStateBag.Deserialize(element));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user