mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
ad95f2f2fa
* .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.
294 lines
13 KiB
C#
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;
|
|
}
|