Files
Roger Barreto ad95f2f2fa .NET: Add Hosted-MemoryAgent sample with isolation key plumbing (#5692) (#5702)
* .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.
2026-05-15 05:42:12 +00:00

294 lines
13 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Azure;
using Azure.AI.Projects;
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;
// Foundry hosted agent test container for Foundry.Hosting.IntegrationTests.
//
// One image, many scenarios. The IT_SCENARIO environment variable selects which agent
// behavior is wired up at startup. Each scenario corresponds to one test fixture and
// one set of tests in the IT project.
//
// The platform injects FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_AGENT_NAME, FOUNDRY_AGENT_VERSION,
// PORT, and APPLICATIONINSIGHTS_CONNECTION_STRING. We never set FOUNDRY_* or AGENT_* names
// from the test side because they are reserved by the platform.
var scenario = Environment.GetEnvironmentVariable("IT_SCENARIO") ?? "happy-path";
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
var projectClient = new AIProjectClient(projectEndpoint, new DefaultAzureCredential());
AIAgent agent = scenario switch
{
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
"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}'.")
};
var builder = WebApplication.CreateBuilder(args);
var port = Environment.GetEnvironmentVariable("PORT");
if (!string.IsNullOrEmpty(port))
{
builder.WebHost.UseUrls($"http://+:{port}");
}
builder.Services.AddFoundryResponses(agent);
var app = builder.Build();
app.MapFoundryResponses();
app.MapGet("/readiness", () => Results.Ok());
app.Run();
static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful AI assistant. Always reply with exactly the single word ECHO unless the user explicitly asks a question that requires a different answer.",
name: "happy-path-agent",
description: "Round trip and conversation test agent.");
static AIAgent CreateToolCallingAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant. Use the GetUtcNow and Multiply tools when appropriate.",
name: "tool-calling-agent",
description: "Server side tool calling test agent.",
tools: [
AIFunctionFactory.Create(GetUtcNow),
AIFunctionFactory.Create(Multiply)
]);
static AIAgent CreateToolCallingApprovalAgent(AIProjectClient client, string deployment) =>
// TODO: wire approval required AIFunction once the public surface is finalized.
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant. Use the SendEmail tool when asked to send a message; it requires user approval before running.",
name: "tool-calling-approval-agent",
description: "Approval flow test agent (placeholder).",
tools: [
AIFunctionFactory.Create(SendEmail)
]);
static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) =>
// TODO: wire MCP toolbox client to https://learn.microsoft.com/api/mcp.
client.AsAIAgent(
model: deployment,
instructions: "You are an assistant with access to Microsoft Learn documentation via MCP.",
name: "mcp-toolbox-agent",
description: "MCP toolbox test agent (placeholder).");
static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deployment) =>
// TODO: substitute custom IResponsesStorageProvider in DI.
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant.",
name: "custom-storage-agent",
description: "Custom storage test agent (placeholder).");
static AIAgent CreateAzureSearchRagAgent(AIProjectClient client, string deployment)
{
// The fixture (AzureSearchRagHostedAgentFixture) injects AZURE_SEARCH_ENDPOINT and
// AZURE_SEARCH_INDEX_NAME into the hosted agent definition. The index is provisioned
// out of band (see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md for the
// required schema and seed content); the container only needs read access. The
// agent's managed identity must hold 'Search Index Data Reader' on the search service
// scope.
var searchEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set for IT_SCENARIO=azure-search-rag."));
var indexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set for IT_SCENARIO=azure-search-rag.");
var searchClient = new SearchClient(searchEndpoint, indexName, new DefaultAzureCredential());
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};
return client.AsAIAgent(new ChatClientAgentOptions
{
Name = "azure-search-rag-agent",
ChatOptions = new ChatOptions
{
ModelId = deployment,
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
"Answer questions using the provided context and cite the source document when available.",
},
AIContextProviders = [new TextSearchProvider(CreateAzureSearchAdapter(searchClient), options)]
});
}
static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
CreateAzureSearchAdapter(SearchClient client, int top = 3) =>
async (query, cancellationToken) =>
{
var searchOptions = new SearchOptions { Size = top };
Response<SearchResults<SearchDocument>> response =
await client.SearchAsync<SearchDocument>(query, searchOptions, cancellationToken).ConfigureAwait(false);
var results = new List<TextSearchProvider.TextSearchResult>();
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
{
results.Add(new TextSearchProvider.TextSearchResult
{
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
RawRepresentation = hit
});
}
return results;
};
// session-files scenario: agent reads files from $HOME inside the per-session sandbox volume.
// Mirrors the dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files sample.
static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: """
You are a friendly assistant that helps users inspect and summarise
files stored in the session sandbox at $HOME.
Always answer file-related questions by calling the available tools
(GetHomeDirectory, ListFiles, ReadFile). Do not guess file paths or
contents read the file before answering.
Quote numbers and figures verbatim from the file rather than
paraphrasing them.
""",
name: "session-files-agent",
description: "Reads files from the per-session $HOME volume.",
tools: [
AIFunctionFactory.Create(GetHomeDirectory),
AIFunctionFactory.Create(ListFiles),
AIFunctionFactory.Create(ReadFile)
]);
// 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");
[Description("Multiplies two integers and returns the product.")]
static int Multiply([Description("First operand")] int a, [Description("Second operand")] int b) => a * b;
[Description("Sends an email. Requires user approval.")]
static string SendEmail(
[Description("Recipient address")] string to,
[Description("Email subject")] string subject) =>
$"Email sent to {to} with subject '{subject}'.";
// session-files tools: resolve paths against $HOME (the per-session sandbox volume).
[Description("Get the absolute path of the session home directory ($HOME).")]
static string GetHomeDirectory() => SessionHome();
[Description("List files and directories under the given path inside the session sandbox. Pass an empty string to list $HOME.")]
static string[] ListFiles(
[Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
{
try
{
return Directory.EnumerateFileSystemEntries(ResolveSessionPath(path)).ToArray();
}
catch (Exception ex)
{
return [$"Error listing '{path}': {ex.Message}"];
}
}
[Description("Read the full text contents of a file inside the session sandbox.")]
static string ReadFile(
[Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
{
try
{
return File.ReadAllText(ResolveSessionPath(path));
}
catch (Exception ex)
{
return $"Error reading '{path}': {ex.Message}";
}
}
static string SessionHome() =>
Environment.GetEnvironmentVariable("HOME")
?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Resolve a caller-supplied path against $HOME, rejecting absolute paths and traversal segments
// so that the model cannot read or list arbitrary container files via the ReadFile/ListFiles
// tools (defense-in-depth against indirect prompt injection). Mirrors the canonicalize +
// startsWith($HOME) pattern used by FileSystemAgentFileStore.ResolveSafePath.
static string ResolveSessionPath(string path)
{
string home = SessionHome();
string homeFull = Path.GetFullPath(home);
string homePrefix = homeFull.EndsWith(Path.DirectorySeparatorChar)
? homeFull
: homeFull + Path.DirectorySeparatorChar;
if (string.IsNullOrWhiteSpace(path))
{
return homeFull;
}
if (Path.IsPathRooted(path))
{
throw new ArgumentException($"Absolute paths are not allowed: '{path}'.", nameof(path));
}
string combined = Path.Combine(homeFull, path);
string fullPath = Path.GetFullPath(combined);
if (!fullPath.Equals(homeFull, StringComparison.Ordinal) &&
!fullPath.StartsWith(homePrefix, StringComparison.Ordinal))
{
throw new ArgumentException(
$"Path '{path}' resolves outside the session sandbox.", nameof(path));
}
return fullPath;
}