mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET [WIP] Foundry Hosted Agents Support (#5312)
* Add Azure AI Foundry Responses hosting adapter Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework AIAgents and workflows within Azure Foundry as hosted agents via the Azure.AI.AgentServer.Responses SDK. - AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution - InputConverter: converts Responses API inputs/history to MEAI ChatMessage - OutputConverter: converts agent response updates to SSE event stream - ServiceCollectionExtensions: DI registration helpers - 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM) - ResponseStreamValidator: SSE protocol validation tool for samples - FoundryResponsesHosting sample app Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump System.ClientModel to 1.10.0 for Azure.Core 1.52.0 compat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clean up tests and sample formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Azure.AI.AgentServer packages to 1.0.0-alpha.20260401.5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add hosted package version suffix (0.9.0-hosted) to distinguish from mainline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move Foundry Responses hosting into Microsoft.Agents.AI.Foundry package Move source and test files from the standalone Hosting.AzureAIResponses project into the Foundry package under a Hosting/ subfolder. This consolidates the Foundry-specific hosting adapter into the main Foundry package. - Source: Microsoft.Agents.AI.Foundry.Hosting namespace - Tests: merged into Foundry.UnitTests/Hosting/ - Conditionally compiled for .NETCoreApp TFMs only (net8.0+) - Deleted standalone Hosting.AzureAIResponses project and test project - Updated sample and solution references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump package version to 0.9.0-hosted.260402.2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump OpenTelemetry packages to fix NU1109 downgrade errors - OpenTelemetry/Api/Exporter.Console/Exporter.InMemory: 1.13.1 -> 1.15.0 - OpenTelemetry.Exporter.OpenTelemetryProtocol: already 1.15.0 - OpenTelemetry.Extensions.Hosting: already 1.14.0 - OpenTelemetry.Instrumentation.AspNetCore/Http: already 1.14.0 - OpenTelemetry.Instrumentation.Runtime: 1.13.0 -> 1.14.0 - Azure.Monitor.OpenTelemetry.Exporter: 1.4.0 -> 1.5.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogWarning with IsEnabled check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix model override bug and add client REPL sample - InputConverter: stop propagating request.Model to ChatOptions.ModelId Hosted agents use their own model; client-provided model values like 'hosted-agent' were being passed through and causing server errors. - Add FoundryResponsesRepl sample: interactive CLI client that connects to a Foundry Responses endpoint using ResponsesClient.AsAIAgent() - Bump package version to 0.9.0-hosted.260403.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Catch agent errors and emit response.failed with real error message Previously, unhandled exceptions from agent execution would bubble up to the SDK orchestrator, which emits a generic 'An internal server error occurred.' message — hiding the actual cause (e.g., 401 auth failures, model not found, etc.). Now AgentFrameworkResponseHandler catches non-cancellation exceptions and emits a proper response.failed event containing the real error message, making it visible to clients and in logs. OperationCanceledException still propagates for proper cancellation handling by the SDK. Also bumps package version to 0.9.0-hosted.260403.2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Renaming and merging hosting extensions. (#5091) * Rename AddAgentFrameworkHandler to AddFoundryResponses and add MapFoundryResponses - Rename extension methods: AddAgentFrameworkHandler -> AddFoundryResponses, MapAgentFrameworkHandler -> MapFoundryResponses - AddFoundryResponses now calls AddResponsesServer() internally - Add MapFoundryResponses() extension on IEndpointRouteBuilder - Update sample and tests to use new API names - Remove redundant AddResponsesServer() and /ready endpoint from sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing numbering in sample. --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address breaking changes in 260408 * Bump hosted internal package version * Add UserAgent middleware tests for Foundry hosting * Hosting Samples update * Hosting Samples update * Hosting Samples update * Hosting Samples update * ChatClientAgent working * Adding SessionStorage and SessionManagement, improving samples to align Consumption vs Hosting * Using updates * Update chat client agent for contributor and devs * Foundry Agent Hosting * Address text rag sample working * Version bump * Adding LocalTools + Workflow samples * Removing extra using samples * Add Hosted-McpTools sample with dual MCP pattern Demonstrates two MCP integration layers in a single hosted agent: - Client-side MCP: McpClient connects to Microsoft Learn, agent handles tool invocations locally (docs_search, code_sample_search, docs_fetch) - Server-side MCP: HostedMcpServerTool delegates tool discovery and invocation to the LLM provider (Responses API), no local connection Includes DevTemporaryTokenCredential for Docker local debugging, Dockerfile.contributor for ProjectReference builds, and the openai/v1 route mapping for AIProjectClient compatibility in Development mode. * .NET: Bump Azure.AI.AgentServer packages to 1.0.0-beta.1/beta.21 and fix br… (#5287) * Bump Azure.AI.AgentServer packages to 1.0.0-beta.1/beta.21 and fix breaking API changes - Azure.AI.AgentServer.Core: 1.0.0-beta.11 -> 1.0.0-beta.21 - Azure.AI.AgentServer.Invocations: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1 - Azure.AI.AgentServer.Responses: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1 - Azure.Identity: 1.20.0 -> 1.21.0 (transitive requirement) - Azure.Core: 1.52.0 -> 1.53.0 (transitive requirement) - Remove azure-sdk-for-net dev feed (packages now on nuget.org) - Fix OutputConverter for new builder API (auto-tracked children, split EmitTextDone/EmitDone) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing small issues. --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Azure AI Foundry Responses hosting adapter Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework AIAgents and workflows within Azure Foundry as hosted agents via the Azure.AI.AgentServer.Responses SDK. - AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution - InputConverter: converts Responses API inputs/history to MEAI ChatMessage - OutputConverter: converts agent response updates to SSE event stream - ServiceCollectionExtensions: DI registration helpers - 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM) - ResponseStreamValidator: SSE protocol validation tool for samples - FoundryResponsesHosting sample app Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump System.ClientModel to 1.10.0 for Azure.Core 1.52.0 compat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clean up tests and sample formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Azure.AI.AgentServer packages to 1.0.0-alpha.20260401.5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add hosted package version suffix (0.9.0-hosted) to distinguish from mainline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move Foundry Responses hosting into Microsoft.Agents.AI.Foundry package Move source and test files from the standalone Hosting.AzureAIResponses project into the Foundry package under a Hosting/ subfolder. This consolidates the Foundry-specific hosting adapter into the main Foundry package. - Source: Microsoft.Agents.AI.Foundry.Hosting namespace - Tests: merged into Foundry.UnitTests/Hosting/ - Conditionally compiled for .NETCoreApp TFMs only (net8.0+) - Deleted standalone Hosting.AzureAIResponses project and test project - Updated sample and solution references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump package version to 0.9.0-hosted.260402.2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump OpenTelemetry packages to fix NU1109 downgrade errors - OpenTelemetry/Api/Exporter.Console/Exporter.InMemory: 1.13.1 -> 1.15.0 - OpenTelemetry.Exporter.OpenTelemetryProtocol: already 1.15.0 - OpenTelemetry.Extensions.Hosting: already 1.14.0 - OpenTelemetry.Instrumentation.AspNetCore/Http: already 1.14.0 - OpenTelemetry.Instrumentation.Runtime: 1.13.0 -> 1.14.0 - Azure.Monitor.OpenTelemetry.Exporter: 1.4.0 -> 1.5.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogWarning with IsEnabled check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix model override bug and add client REPL sample - InputConverter: stop propagating request.Model to ChatOptions.ModelId Hosted agents use their own model; client-provided model values like 'hosted-agent' were being passed through and causing server errors. - Add FoundryResponsesRepl sample: interactive CLI client that connects to a Foundry Responses endpoint using ResponsesClient.AsAIAgent() - Bump package version to 0.9.0-hosted.260403.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Catch agent errors and emit response.failed with real error message Previously, unhandled exceptions from agent execution would bubble up to the SDK orchestrator, which emits a generic 'An internal server error occurred.' message — hiding the actual cause (e.g., 401 auth failures, model not found, etc.). Now AgentFrameworkResponseHandler catches non-cancellation exceptions and emits a proper response.failed event containing the real error message, making it visible to clients and in logs. OperationCanceledException still propagates for proper cancellation handling by the SDK. Also bumps package version to 0.9.0-hosted.260403.2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Renaming and merging hosting extensions. (#5091) * Rename AddAgentFrameworkHandler to AddFoundryResponses and add MapFoundryResponses - Rename extension methods: AddAgentFrameworkHandler -> AddFoundryResponses, MapAgentFrameworkHandler -> MapFoundryResponses - AddFoundryResponses now calls AddResponsesServer() internally - Add MapFoundryResponses() extension on IEndpointRouteBuilder - Update sample and tests to use new API names - Remove redundant AddResponsesServer() and /ready endpoint from sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing numbering in sample. --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address breaking changes in 260408 * Bump hosted internal package version * Add UserAgent middleware tests for Foundry hosting * Hosting Samples update * Hosting Samples update * Hosting Samples update * Hosting Samples update * ChatClientAgent working * Adding SessionStorage and SessionManagement, improving samples to align Consumption vs Hosting * Using updates * Update chat client agent for contributor and devs * Foundry Agent Hosting * Address text rag sample working * Version bump * Adding LocalTools + Workflow samples * Removing extra using samples * Add Hosted-McpTools sample with dual MCP pattern Demonstrates two MCP integration layers in a single hosted agent: - Client-side MCP: McpClient connects to Microsoft Learn, agent handles tool invocations locally (docs_search, code_sample_search, docs_fetch) - Server-side MCP: HostedMcpServerTool delegates tool discovery and invocation to the LLM provider (Responses API), no local connection Includes DevTemporaryTokenCredential for Docker local debugging, Dockerfile.contributor for ProjectReference builds, and the openai/v1 route mapping for AIProjectClient compatibility in Development mode. * Bump Azure.AI.AgentServer packages to 1.0.0-beta.1/beta.21 and fix breaking API changes - Azure.AI.AgentServer.Core: 1.0.0-beta.11 -> 1.0.0-beta.21 - Azure.AI.AgentServer.Invocations: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1 - Azure.AI.AgentServer.Responses: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1 - Azure.Identity: 1.20.0 -> 1.21.0 (transitive requirement) - Azure.Core: 1.52.0 -> 1.53.0 (transitive requirement) - Remove azure-sdk-for-net dev feed (packages now on nuget.org) - Fix OutputConverter for new builder API (auto-tracked children, split EmitTextDone/EmitDone) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing small issues. * Fix IDE0009: add 'this' qualification in DevTemporaryTokenCredential Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix IDE0009: add 'this' qualification in all HostedAgentsV2 samples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CHARSET: add UTF-8 BOM to Hosted-LocalTools and Hosted-Workflows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix dotnet format: add Async suffix to test methods (IDE1006), fix encoding and style Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Register AgentSessionStore in test DI setups Add InMemoryAgentSessionStore registration to all ServiceCollection setups in AgentFrameworkResponseHandlerTests and WorkflowIntegrationTests. This is needed after the AgentSessionStore infrastructure was introduced in the responses-hosting feature. Tests still have NotImplementedException stubs for CreateSessionCoreAsync which will be fixed when the session infrastructure is fully available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Invocations protocol samples (hosted echo agent + client) (#5278) Add Hosted-Invocations-EchoAgent: a minimal echo agent hosted via the Invocations protocol (POST /invocations) using AddInvocationsServer and MapInvocationsServer, bridged to an Agent Framework AIAgent through a custom InvocationHandler. Add SimpleInvocationsAgent: a console REPL client that wraps HttpClient calls to the /invocations endpoint in a custom InvocationsAIAgent, demonstrating programmatic consumption of the Invocations protocol. Both samples default to port 8088 for consistency with other hosted agent samples. * Restructure FoundryHostedAgents samples into invocations/ and responses/ Align dotnet hosted agent samples with the Python side (PR #5281) by reorganizing the directory structure: - Remove HostedAgentsV1 entirely (old API pattern) - Split HostedAgentsV2 into invocations/ and responses/ based on protocol - Move Using-Samples accordingly (SimpleAgent to responses, SimpleInvocationsAgent to invocations) - Update slnx with new project paths and add previously missing invocations projects - Update README cd paths from HostedAgentsV2 to invocations or responses - Rename .env.local to .env.example to match Python naming convention - Fix format violations in newly included invocations projects * Remove launchSettings, use .env for port configuration - Delete all launchSettings.json files (port 8088 now comes from ASPNETCORE_URLS in .env) - Add DotNetEnv to Hosted-Invocations-EchoAgent so it loads .env like the responses samples - Create .env.example for EchoAgent with ASPNETCORE_URLS and ASPNETCORE_ENVIRONMENT - Add AGENT_NAME to ChatClientAgent and FoundryAgent .env.example (required by those samples) - Add AZURE_BEARER_TOKEN=DefaultAzureCredential to all .env.example files - Update DevTemporaryTokenCredential in all 6 samples to treat the sentinel value as unavailable, allowing ChainedTokenCredential to fall through to DefaultAzureCredential - Update EchoAgent README with Configuration section * Use placeholder for AGENT_NAME in Hosted-FoundryAgent .env.example * Move FoundryResponsesHosting to responses/Hosted-WorkflowHandoff, use GetResponsesClient * Rename Hosted-Workflows to Hosted-Workflow-Simple, Hosted-WorkflowHandoff to Hosted-Workflow-Handoff * Remove FoundryResponsesRepl and empty FoundryResponsesHosting directory * Add Dockerfiles, README, agent yamls and bearer token support to Hosted-Workflow-Handoff - Add Dockerfile and Dockerfile.contributor for Docker-based testing - Add agent.yaml and agent.manifest.yaml with triage-workflow as primary agent - Add README.md following sibling pattern, noting Azure OpenAI vs Foundry endpoint - Add DevTemporaryTokenCredential and ChainedTokenCredential for Docker auth - Register triage-workflow as non-keyed default so azd invoke works without model - Update .env.example with AZURE_BEARER_TOKEN sentinel - Add .gitignore to 04-hosting to suppress VS-generated launchSettings.json - Fix docker run image name in Hosted-Workflow-Simple README * Fix AgentFrameworkResponseHandlerTests: implement session methods in test mock agents * .NET: Auto-instrument resolved AIAgents with OpenTelemetry for Foundry Hosted Agents (#5316) * Auto-instrument resolved AIAgents with OpenTelemetry using Core ResponsesSourceName * Add OTel telemetry capture tests for Foundry hosted agent handler * Net: Prepare Foundry Preview Release (#5336) * Prepare Foundry preview release 1.2.0-preview.* Bump VersionPrefix to 1.2.0 and update the preview stamp date. Invert packaging opt-in so only the Foundry preview set produces NuGet packages: - Microsoft.Agents.AI.Abstractions - Microsoft.Agents.AI - Microsoft.Agents.AI.Workflows - Microsoft.Agents.AI.Workflows.Generators - Microsoft.Agents.AI.Foundry Flip IsReleased=false on the preview set so they pick up the -preview.YYMMDD.N suffix. Gate GeneratePackageOnBuild on IsPackable=true. Remove the global IsPackable=true from nuget-package.props so the repo-level default (false) applies to everything else. * Lower preview VersionPrefix to 0.0.1 Retroactive preview publish: bump VersionPrefix and GitTag from 1.2.0 to 0.0.1 so the 5 Foundry preview packages emit as 0.0.1-preview.260417.1. * Net: Publish all packages as 0.0.1-preview.260417.2 (#5341) Revises the Foundry pre-release approach to publish ALL normally packable src projects as preview packages stamped 0.0.1-preview.260417.2, including projects previously flagged IsReleased=true or with a non-default VersionSuffix (rc/alpha). nuget-package.props: - Collapse the four conditional PackageVersion expressions (IsReleaseCandidate, VersionSuffix, default preview, IsReleased stable) into a single unconditional 0.0.1-preview.260417.2. On this preview-only branch every package ships with the same pre-release stamp regardless of per-project flags. - Restore the global IsPackable=true default (offsetting the repo-wide IsPackable=false in Directory.Build.props). Projects that opt out (Mem0, Declarative) already set IsPackable=false AFTER importing this file so they remain non-packable. - Remove the IsReleased-gated EnablePackageValidation line. Package validation does not apply to a 0.0.1 preview. csproj reverts (Abstractions, Agents.AI, Workflows, Workflows.Generators, Foundry): - Revert the IsPackable=true opt-in block introduced in #5336 (now redundant since the props default is true again). - Restore IsReleased=true to its pre-PR value. The setting is now a no-op because the props no longer branches on it. * Bump preview version to 260420.1 and fix AgentServer package deps (#5367) - Bump PackageVersion to 0.0.1-preview.260420.1 - Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by Azure.AI.AgentServer.Responses beta.3) - Replace AgentHostTelemetry.ResponsesSourceName with local constant (type made internal in AgentServer.Core beta.22) Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Hosted agents toolbox support (#5368) * feat: Add Foundry Toolbox (MCP) support to AgentFrameworkResponseHandler Adds support for Foundry Toolsets MCP proxy integration in the hosted agent response handler. Toolsets connect at startup via IHostedService, gating the readiness probe per spec §3.1. MCP tools are injected into every request's ChatOptions and OAuth consent errors (-32006) are intercepted and surfaced as mcp_approval_request + incomplete SSE events. New files: - FoundryToolboxOptions.cs: configuration POCO for toolset names and API version - FoundryToolboxBearerTokenHandler.cs: DelegatingHandler with Azure Bearer token auth, Foundry-Features header injection, and 3x exponential backoff on 429/5xx - McpConsentContext.cs: AsyncLocal-based per-request consent state shared between the tool wrapper and the response handler - ConsentAwareMcpClientTool.cs: AIFunction wrapper that catches -32006 errors and signals consent via shared state and linked CancellationTokenSource - FoundryToolboxService.cs: IHostedService that creates McpClient per toolset at startup and exposes cached tools Modified files: - AgentFrameworkResponseHandler.cs: injects toolbox tools into ChatOptions, sets up linked CTS consent interception, emits mcp_approval_request on -32006 - ServiceCollectionExtensions.cs: adds AddFoundryToolboxes(params string[]) extension - Microsoft.Agents.AI.Foundry.csproj: adds ModelContextProtocol and Azure.Identity dependencies under NETCoreApp condition Sample: - Hosted-Toolbox: minimal hosted agent sample using AddFoundryToolboxes * Rename toolset to toolbox in user-facing API; rename ConsentAwareMcpClientTool to ConsentAwareMcpClientAIFunction * Add HostedMcpToolboxAITool for client-selectable Foundry toolboxes Introduces HostedMcpToolboxAITool, a marker tool subclassing HostedMcpServerTool that rides the OpenAI Responses 'mcp' wire format to let clients request a specific Foundry toolbox per request. - New FoundryAITool.CreateHostedMcpToolbox(name, version?) factory. - FoundryToolboxOptions.StrictMode (default true) rejects unregistered toolboxes; set to false to allow lazy-open on first use. - FoundryToolboxService.GetToolboxToolsAsync(name, version?) resolves cached or lazy-opened MCP tools. - AgentFrameworkResponseHandler parses request.Tools for foundry-toolbox://name[?version=v] markers and injects resolved tools per request, merging with pre-registered ones. - Unit tests for marker parsing and strict-mode resolution. * Bump Azure.AI.Projects to 2.1.0-alpha; add ToolboxRecord/ToolboxVersion factory overloads + tests * Fix PR review issues: retry off-by-one, URI encoding, docs, tests, build - Fix off-by-one in FoundryToolboxBearerTokenHandler retry loop (4 attempts → 3) - URI-encode version parameter in HostedMcpToolboxAITool.BuildAddress - Add XML doc clarifying version pinning is reserved for future use - Add comment clarifying AddHostedService deduplication safety - Fix DevTemporaryTokenCredential expiry to use DateTimeOffset.MaxValue - Fix AgentCard ambiguity in A2AServer sample with using alias - Add 18 new unit tests for retry handler and ReadMcpToolboxMarkers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Hosted agent adapter (#5371) * Bump preview version to 260420.1 and fix AgentServer package deps - Bump PackageVersion to 0.0.1-preview.260420.1 - Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by Azure.AI.AgentServer.Responses beta.3) - Replace AgentHostTelemetry.ResponsesSourceName with local constant (type made internal in AgentServer.Core beta.22) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy the CA1873 analyzer rule which flags potentially expensive argument evaluation when logging is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Hosted agent adapter (#5374) * Bump preview version to 260420.1 and fix AgentServer package deps - Bump PackageVersion to 0.0.1-preview.260420.1 - Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by Azure.AI.AgentServer.Responses beta.3) - Replace AgentHostTelemetry.ResponsesSourceName with local constant (type made internal in AgentServer.Core beta.22) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy the CA1873 analyzer rule which flags potentially expensive argument evaluation when logging is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bumping NuGet version --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Hosted agent adapter (#5406) * Bump preview version to 260420.1 and fix AgentServer package deps - Bump PackageVersion to 0.0.1-preview.260420.1 - Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by Azure.AI.AgentServer.Responses beta.3) - Replace AgentHostTelemetry.ResponsesSourceName with local constant (type made internal in AgentServer.Core beta.22) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy the CA1873 analyzer rule which flags potentially expensive argument evaluation when logging is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bumping NuGet version * Restore conditional versioning, remove dev feed, bump Azure.AI.Projects to beta.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Hosted agent adapter (#5408) * Bump preview version to 260420.1 and fix AgentServer package deps - Bump PackageVersion to 0.0.1-preview.260420.1 - Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by Azure.AI.AgentServer.Responses beta.3) - Replace AgentHostTelemetry.ResponsesSourceName with local constant (type made internal in AgentServer.Core beta.22) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy the CA1873 analyzer rule which flags potentially expensive argument evaluation when logging is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bumping NuGet version * Restore conditional versioning, remove dev feed, bump Azure.AI.Projects to beta.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #5312 review comments - Add comment explaining NU1903 suppression (Microsoft.Bcl.Memory transitive vuln) - Remove NU1903 from sample/test projects where not needed - Fix Dockerfile ENTRYPOINT mismatch in Hosted-Workflow-Simple - Align agent name to 'hosted-workflow-simple' in agent.yaml and README - Fix Hosted-McpTools README: replace GitHub PAT refs with Microsoft Learn - Fix session persistence: only persist when client provides conversation ID - Upgrade IsNullOrEmpty to IsNullOrWhiteSpace for session ID checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Split Foundry into stable V1 and preview Hosting package Extract hosted agent functionality from Microsoft.Agents.AI.Foundry into a new Microsoft.Agents.AI.Foundry.Hosting preview package. This resolves NU5104 build errors caused by the stable Foundry package depending on prerelease Azure SDK packages (Azure.AI.AgentServer.Responses, Azure.AI.Projects beta). Changes: - Create Microsoft.Agents.AI.Foundry.Hosting with VersionSuffix=preview, targeting .NET Core only (net8.0/9.0/10.0) - Move all Hosting/ source files to the new project - Move ToolboxRecord/ToolboxVersion overloads to FoundryAIToolExtensions - Revert Azure.AI.Projects to 2.0.0 in Directory.Packages.props; Hosting uses VersionOverride for 2.1.0-beta.1 - Clean V1 Foundry csproj: remove beta deps, ASP.NET Core ref, hosting conditionals - Update 8 hosted agent sample projects to reference Foundry.Hosting - Split unit tests: ToolboxRecord/ToolboxVersion tests moved to Hosting/ - Add Foundry.Hosting to solution file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review comments: experimental attrs, doc fixes, token propagation - Add [Experimental(OPENAI001)] to all 7 public Hosting types per reviewer request - Fix McpConsentContext XML doc: 'Thread-static' -> 'Async-local' (AsyncLocal flows with ExecutionContext, not thread-static) - Expand UserAgentMiddleware test regex to match prerelease versions (e.g. 1.0.0-rc.4) - Propagate CancellationToken in AgentFrameworkResponseHandler session save Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary MEAI001 suppression from stable Foundry package MEAI001 was a leftover from when Hosting code lived in the same project. The stable V1 Foundry package builds clean without it, and suppressing experimental diagnostics in a released package can hide unintentional exposure of experimental APIs to consumers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Foundry.Hosting to release solution filter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
This commit is contained in:
co-authored by
Copilot
alliscode
Ben Thomas
parent
57fa8ea902
commit
f2b215a2f6
@@ -0,0 +1,379 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ResponseHandler"/> implementation that bridges the Azure AI Responses Server SDK
|
||||
/// with agent-framework <see cref="AIAgent"/> instances, enabling agent-framework agents and workflows
|
||||
/// to be hosted as Azure Foundry Hosted Agents.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
|
||||
private readonly FoundryToolboxService? _toolboxService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
|
||||
/// that resolves agents from keyed DI services.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The service provider for resolving agents.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="toolboxService">Optional Foundry Toolbox service providing MCP tools.</param>
|
||||
public AgentFrameworkResponseHandler(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AgentFrameworkResponseHandler> logger,
|
||||
FoundryToolboxService? toolboxService = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
this._serviceProvider = serviceProvider;
|
||||
this._logger = logger;
|
||||
this._toolboxService = toolboxService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
|
||||
CreateResponse request,
|
||||
ResponseContext context,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Resolve agent
|
||||
var agent = this.ResolveAgent(request);
|
||||
var sessionStore = this.ResolveSessionStore(request);
|
||||
|
||||
// 2. Load or create a new session from the interaction
|
||||
var sessionConversationId = request.GetConversationId();
|
||||
|
||||
var chatClientAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId)
|
||||
? await sessionStore.GetSessionAsync(agent, sessionConversationId, cancellationToken).ConfigureAwait(false)
|
||||
: chatClientAgent is not null
|
||||
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
|
||||
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 3. Create the SDK event stream builder
|
||||
var stream = new ResponseEventStream(context, request);
|
||||
|
||||
// 3. Emit lifecycle events
|
||||
yield return stream.EmitCreated();
|
||||
yield return stream.EmitInProgress();
|
||||
|
||||
// 4. Convert input: history + current input → ChatMessage[]
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Load conversation history if available
|
||||
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history));
|
||||
}
|
||||
|
||||
// Load and convert current input items
|
||||
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (inputItems.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to raw request input
|
||||
messages.AddRange(InputConverter.ConvertInputToMessages(request));
|
||||
}
|
||||
|
||||
// 5. Build chat options
|
||||
var chatOptions = InputConverter.ConvertToChatOptions(request);
|
||||
chatOptions.Instructions = request.Instructions;
|
||||
|
||||
// Inject Foundry Toolbox tools when the toolbox service is available.
|
||||
//
|
||||
// Two sources are considered:
|
||||
// 1. Pre-registered toolboxes (via AddFoundryToolboxes) — always appended.
|
||||
// 2. Per-request markers embedded in request.Tools (HostedMcpToolboxAITool)
|
||||
// whose ServerAddress scheme is "foundry-toolbox://". Strict mode rejects
|
||||
// unknown names; otherwise a lazy MCP client is opened and cached.
|
||||
//
|
||||
// Each toolbox's tools are only appended once per request, even if it appears
|
||||
// in both the pre-registered list and the per-request markers.
|
||||
if (this._toolboxService is not null)
|
||||
{
|
||||
List<AITool>? toolsToAdd = null;
|
||||
|
||||
if (this._toolboxService.Tools.Count > 0)
|
||||
{
|
||||
toolsToAdd = [.. this._toolboxService.Tools];
|
||||
}
|
||||
|
||||
var markers = InputConverter.ReadMcpToolboxMarkers(request);
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? resolutionError = null;
|
||||
|
||||
foreach (var (name, version) in markers)
|
||||
{
|
||||
if (!seen.Add(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<AITool>? toolboxTools = null;
|
||||
try
|
||||
{
|
||||
toolboxTools = await this._toolboxService
|
||||
.GetToolboxToolsAsync(name, version, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
this._logger.LogWarning(
|
||||
ex,
|
||||
"Foundry toolbox '{ToolboxName}' could not be resolved for response {ResponseId}.",
|
||||
name,
|
||||
context.ResponseId);
|
||||
}
|
||||
|
||||
resolutionError = ex.Message;
|
||||
break;
|
||||
}
|
||||
|
||||
toolsToAdd ??= [];
|
||||
foreach (var t in toolboxTools)
|
||||
{
|
||||
if (!toolsToAdd.Contains(t))
|
||||
{
|
||||
toolsToAdd.Add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolutionError is not null)
|
||||
{
|
||||
yield return stream.EmitFailed(ResponseErrorCode.ServerError, resolutionError);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (toolsToAdd?.Count > 0)
|
||||
{
|
||||
chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolsToAdd];
|
||||
}
|
||||
}
|
||||
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// 6. Set up consent context for -32006 OAuth consent interception.
|
||||
// We create a linked CTS so the consent-aware tool wrapper can cancel the agent
|
||||
// run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState
|
||||
// is a shared mutable object that flows via AsyncLocal to the tool wrapper.
|
||||
using var consentCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var consentState = new RequestConsentState { CancellationSource = consentCts };
|
||||
McpConsentContext.Current.Value = consentState;
|
||||
|
||||
// 7. Run the agent and convert output
|
||||
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
|
||||
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
|
||||
bool emittedTerminal = false;
|
||||
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
|
||||
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
|
||||
stream,
|
||||
cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
bool shutdownDetected = false;
|
||||
McpConsentInfo? consentInfo = null;
|
||||
ResponseStreamEvent? failedEvent = null;
|
||||
ResponseStreamEvent? evt = null;
|
||||
try
|
||||
{
|
||||
if (!await enumerator.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
evt = enumerator.Current;
|
||||
}
|
||||
catch (OperationCanceledException) when (!emittedTerminal && consentState.Pending is not null)
|
||||
{
|
||||
// -32006 consent error: the tool wrapper cancelled consentCts and stored consent info.
|
||||
consentInfo = consentState.Pending;
|
||||
}
|
||||
catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal)
|
||||
{
|
||||
shutdownDetected = true;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException && !emittedTerminal)
|
||||
{
|
||||
// Catch agent execution errors and emit a proper failed event
|
||||
// with the real error message instead of letting the SDK emit
|
||||
// a generic "An internal server error occurred."
|
||||
if (this._logger.IsEnabled(LogLevel.Error))
|
||||
{
|
||||
this._logger.LogError(ex, "Agent execution failed for response {ResponseId}.", context.ResponseId);
|
||||
}
|
||||
|
||||
failedEvent = stream.EmitFailed(
|
||||
ResponseErrorCode.ServerError,
|
||||
ex.Message);
|
||||
}
|
||||
|
||||
if (consentInfo is not null)
|
||||
{
|
||||
// Emit mcp_approval_request output item + incomplete for the consent URL.
|
||||
foreach (var approvalEvent in stream.OutputItemMcpApprovalRequest(
|
||||
consentInfo.ToolboxName,
|
||||
consentInfo.ToolName,
|
||||
consentInfo.ConsentUrl))
|
||||
{
|
||||
yield return approvalEvent;
|
||||
}
|
||||
|
||||
yield return stream.EmitIncomplete(reason: null);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (failedEvent is not null)
|
||||
{
|
||||
yield return failedEvent;
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (shutdownDetected)
|
||||
{
|
||||
// Server is shutting down — emit incomplete so clients can resume
|
||||
this._logger.LogInformation("Shutdown detected, emitting incomplete response.");
|
||||
yield return stream.EmitIncomplete();
|
||||
yield break;
|
||||
}
|
||||
|
||||
// yield is in the outer try (finally-only) — allowed by C#
|
||||
yield return evt!;
|
||||
|
||||
if (evt is ResponseCompletedEvent or ResponseFailedEvent or ResponseIncompleteEvent)
|
||||
{
|
||||
emittedTerminal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
// Persist session after streaming completes (successful or not)
|
||||
if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId))
|
||||
{
|
||||
await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <see cref="AIAgent"/> from the request.
|
||||
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
|
||||
/// If neither is present, attempts to resolve a default (non-keyed) <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
private AIAgent ResolveAgent(CreateResponse request)
|
||||
{
|
||||
var agentName = GetAgentName(request);
|
||||
|
||||
if (!string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
|
||||
if (agent is not null)
|
||||
{
|
||||
return FoundryHostingExtensions.ApplyOpenTelemetry(agent);
|
||||
}
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
this._logger.LogWarning("Agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName);
|
||||
}
|
||||
}
|
||||
|
||||
// Try non-keyed default
|
||||
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
|
||||
if (defaultAgent is not null)
|
||||
{
|
||||
return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent);
|
||||
}
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent.";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <see cref="AIAgent"/> from the request.
|
||||
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
|
||||
/// If neither is present, attempts to resolve a default (non-keyed) <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
private AgentSessionStore ResolveSessionStore(CreateResponse request)
|
||||
{
|
||||
var agentName = GetAgentName(request);
|
||||
|
||||
if (!string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
var sessionStore = this._serviceProvider.GetKeyedService<AgentSessionStore>(agentName);
|
||||
if (sessionStore is not null)
|
||||
{
|
||||
return sessionStore;
|
||||
}
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
this._logger.LogWarning("SessionStore for agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName);
|
||||
}
|
||||
}
|
||||
|
||||
// Try non-keyed default
|
||||
var defaultSessionStore = this._serviceProvider.GetService<AgentSessionStore>();
|
||||
if (defaultSessionStore is not null)
|
||||
{
|
||||
return defaultSessionStore;
|
||||
}
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore.";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
private static string? GetAgentName(CreateResponse request)
|
||||
{
|
||||
// Try agent.name from AgentReference
|
||||
var agentName = request.AgentReference?.Name;
|
||||
|
||||
// Fall back to "model" field (OpenAI clients send the agent name as the model)
|
||||
if (string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
agentName = request.Model;
|
||||
}
|
||||
|
||||
// Fall back to metadata["entity_id"]
|
||||
if (string.IsNullOrEmpty(agentName) && request.Metadata?.AdditionalProperties is not null)
|
||||
{
|
||||
request.Metadata.AdditionalProperties.TryGetValue("entity_id", out agentName);
|
||||
}
|
||||
|
||||
return agentName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for storing and retrieving agent conversation sessions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations of this interface enable persistent storage of conversation sessions,
|
||||
/// allowing conversations to be resumed across HTTP requests, application restarts,
|
||||
/// or different service instances in hosted scenarios.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public abstract class AgentSessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves a serialized agent session to persistent storage.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent that owns this session.</param>
|
||||
/// <param name="conversationId">The unique identifier for the conversation/session.</param>
|
||||
/// <param name="session">The session to save.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous save operation.</returns>
|
||||
public abstract ValueTask SaveSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
AgentSession session,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a serialized agent session from persistent storage.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent that owns this session.</param>
|
||||
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous retrieval operation.
|
||||
/// The task result contains the session, or a new session if not found.
|
||||
/// </returns>
|
||||
public abstract ValueTask<AgentSession> GetSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIFunction"/> wrapper around <see cref="McpClientTool"/> that intercepts
|
||||
/// JSON-RPC error -32006 (OAuth consent required) from the Foundry Toolsets proxy and
|
||||
/// propagates it back to <see cref="AgentFrameworkResponseHandler"/> via
|
||||
/// <see cref="McpConsentContext"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When the proxy returns -32006, the consent URL is stored in <see cref="McpConsentContext.Current"/>
|
||||
/// and the per-request <see cref="RequestConsentState.CancellationSource"/> is cancelled. This causes
|
||||
/// <see cref="FunctionInvokingChatClient"/> to stop the tool loop (it guards
|
||||
/// exceptions with <c>when (!ct.IsCancellationRequested)</c>) and surfaces an
|
||||
/// <see cref="System.OperationCanceledException"/> to the handler. The handler then emits the
|
||||
/// <c>mcp_approval_request</c> output item and marks the response as <c>incomplete</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ConsentAwareMcpClientAIFunction : AIFunction
|
||||
{
|
||||
private readonly McpClientTool _inner;
|
||||
private readonly string _toolboxName;
|
||||
|
||||
internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName)
|
||||
{
|
||||
this._inner = inner;
|
||||
this._toolboxName = toolboxName;
|
||||
}
|
||||
|
||||
public override string Name => this._inner.Name;
|
||||
|
||||
public override string Description => this._inner.Description;
|
||||
|
||||
public override JsonElement JsonSchema => this._inner.JsonSchema;
|
||||
|
||||
public override JsonElement? ReturnJsonSchema => this._inner.ReturnJsonSchema;
|
||||
|
||||
public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions;
|
||||
|
||||
protected override async ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (McpProtocolException ex) when ((int)ex.ErrorCode == -32006)
|
||||
{
|
||||
var state = McpConsentContext.Current.Value;
|
||||
if (state is not null)
|
||||
{
|
||||
state.Pending = new McpConsentInfo(this._toolboxName, this._inner.Name, ex.Message);
|
||||
state.CancellationSource?.Cancel();
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
throw; // fallback if the CT wasn't cancelled for some reason
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="FoundryAITool"/> that require Azure.AI.Projects 2.1.0-beta.1+
|
||||
/// types (e.g. <see cref="ToolboxRecord"/>, <see cref="ToolboxVersion"/>).
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryAIToolExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an <see cref="AITool"/> marker from a <see cref="ToolboxRecord"/> retrieved
|
||||
/// from <c>AIProjectClient</c>. Uses <see cref="ToolboxRecord.Name"/> and
|
||||
/// <see cref="ToolboxRecord.DefaultVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="toolbox">The toolbox record.</param>
|
||||
/// <returns>An <see cref="AITool"/> marker backed by <see cref="HostedMcpToolboxAITool"/>.</returns>
|
||||
public static AITool CreateHostedMcpToolbox(ToolboxRecord toolbox)
|
||||
{
|
||||
if (toolbox is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(toolbox));
|
||||
}
|
||||
|
||||
return new HostedMcpToolboxAITool(toolbox.Name, toolbox.DefaultVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="AITool"/> marker from a specific <see cref="ToolboxVersion"/>
|
||||
/// retrieved from <c>AIProjectClient</c>. Uses <see cref="ToolboxVersion.Name"/> and
|
||||
/// <see cref="ToolboxVersion.Version"/>.
|
||||
/// </summary>
|
||||
/// <param name="toolboxVersion">The toolbox version.</param>
|
||||
/// <returns>An <see cref="AITool"/> marker backed by <see cref="HostedMcpToolboxAITool"/>.</returns>
|
||||
public static AITool CreateHostedMcpToolbox(ToolboxVersion toolboxVersion)
|
||||
{
|
||||
if (toolboxVersion is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(toolboxVersion));
|
||||
}
|
||||
|
||||
return new HostedMcpToolboxAITool(toolboxVersion.Name, toolboxVersion.Version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="DelegatingHandler"/> that:
|
||||
/// <list type="bullet">
|
||||
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://cognitiveservices.azure.com/.default</c>) per request.</item>
|
||||
/// <item>Injects the <c>Foundry-Features</c> header from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c> when non-empty.</item>
|
||||
/// <item>Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
|
||||
{
|
||||
private const int MaxRetries = 3;
|
||||
private static readonly TokenRequestContext s_tokenContext =
|
||||
new(["https://cognitiveservices.azure.com/.default"]);
|
||||
|
||||
private readonly TokenCredential _credential;
|
||||
private readonly string? _featuresHeaderValue;
|
||||
|
||||
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue)
|
||||
{
|
||||
this._credential = credential;
|
||||
this._featuresHeaderValue = featuresHeaderValue;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await this._credential
|
||||
.GetTokenAsync(s_tokenContext, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
|
||||
if (!string.IsNullOrEmpty(this._featuresHeaderValue))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue);
|
||||
}
|
||||
|
||||
// MaxRetries is the total number of attempts (not additional retries after the first).
|
||||
for (int attempt = 0; attempt < MaxRetries; attempt++)
|
||||
{
|
||||
// Clone the request for retries (the original request cannot be sent twice)
|
||||
HttpRequestMessage requestToSend = attempt == 0
|
||||
? request
|
||||
: await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await base.SendAsync(requestToSend, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode is not (HttpStatusCode.TooManyRequests
|
||||
or HttpStatusCode.InternalServerError
|
||||
or HttpStatusCode.BadGateway
|
||||
or HttpStatusCode.ServiceUnavailable))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
// Last attempt exhausted — return the error response as-is.
|
||||
if (attempt == MaxRetries - 1)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
response.Dispose();
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Unreachable when MaxRetries > 0, but satisfies the compiler.
|
||||
throw new InvalidOperationException("Retry loop completed without returning a response.");
|
||||
}
|
||||
|
||||
private static async Task<HttpRequestMessage> CloneRequestAsync(
|
||||
HttpRequestMessage original,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
|
||||
|
||||
foreach (var header in original.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (original.Content is not null)
|
||||
{
|
||||
var contentBytes = await original.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
clone.Content = new ByteArrayContent(contentBytes);
|
||||
|
||||
foreach (var header in original.Content.Headers)
|
||||
{
|
||||
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for Foundry Toolbox MCP integration.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryToolboxOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the list of toolbox names to connect to at startup.
|
||||
/// Each name corresponds to a toolbox registered in the Foundry project.
|
||||
/// The platform proxy URL is constructed as:
|
||||
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion}</c>
|
||||
/// </summary>
|
||||
public IList<string> ToolboxNames { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Toolsets API version to use when constructing proxy URLs.
|
||||
/// </summary>
|
||||
public string ApiVersion { get; set; } = "2025-05-01-preview";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether per-request toolbox markers (referenced via
|
||||
/// <c>foundry-toolbox://</c> on the wire) are restricted to toolboxes pre-registered
|
||||
/// via <see cref="ToolboxNames"/>. When <see langword="true"/> (the default), a request
|
||||
/// that references an unknown toolbox is rejected. When <see langword="false"/>, the
|
||||
/// server lazily opens an MCP connection for the referenced toolbox on first use and
|
||||
/// caches it.
|
||||
/// </summary>
|
||||
public bool StrictMode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// For testing only: overrides <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c>.
|
||||
/// Not part of the public API.
|
||||
/// </summary>
|
||||
internal string? EndpointOverride { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IHostedService"/> that eagerly connects to the Foundry Toolboxes MCP proxy at
|
||||
/// container startup, discovers tools via <c>tools/list</c>, and caches them so they can be
|
||||
/// injected into every <see cref="ChatOptions"/> by <see cref="AgentFrameworkResponseHandler"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent the service starts without error and
|
||||
/// no tools are registered, keeping the container healthy per spec §2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Startup eagerly connects to every name in <see cref="FoundryToolboxOptions.ToolboxNames"/>.
|
||||
/// Beyond those, per-request toolbox markers (see <see cref="HostedMcpToolboxAITool"/>) are
|
||||
/// resolved at request time through <see cref="GetToolboxToolsAsync"/>. Unknown toolboxes are
|
||||
/// rejected when <see cref="FoundryToolboxOptions.StrictMode"/> is <see langword="true"/> and
|
||||
/// lazily connected otherwise.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
{
|
||||
private readonly FoundryToolboxOptions _options;
|
||||
private readonly TokenCredential _credential;
|
||||
private readonly ILogger<FoundryToolboxService> _logger;
|
||||
|
||||
private readonly Dictionary<string, CachedToolbox> _toolboxes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _lazyOpenLock = new(1, 1);
|
||||
|
||||
private string? _resolvedEndpoint;
|
||||
private string? _featuresHeader;
|
||||
private string _agentName = "hosted-agent";
|
||||
private string _agentVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached list of <see cref="AITool"/> instances discovered from all
|
||||
/// pre-registered toolboxes. Always non-null after startup.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AITool> Tools { get; private set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="FoundryToolboxService"/>.
|
||||
/// </summary>
|
||||
public FoundryToolboxService(
|
||||
IOptions<FoundryToolboxOptions> options,
|
||||
TokenCredential credential,
|
||||
ILogger<FoundryToolboxService>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(credential);
|
||||
|
||||
this._options = options.Value;
|
||||
this._credential = credential;
|
||||
this._logger = logger ?? NullLogger<FoundryToolboxService>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
this._resolvedEndpoint = this._options.EndpointOverride
|
||||
?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
|
||||
|
||||
if (string.IsNullOrEmpty(this._resolvedEndpoint))
|
||||
{
|
||||
this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled.");
|
||||
this.Tools = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this._featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES");
|
||||
this._agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent";
|
||||
this._agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0";
|
||||
|
||||
if (this._options.ToolboxNames.Count == 0)
|
||||
{
|
||||
this._logger.LogInformation("No pre-registered toolbox names configured.");
|
||||
this.Tools = [];
|
||||
return;
|
||||
}
|
||||
|
||||
var allTools = new List<AITool>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var toolboxName in this._options.ToolboxNames)
|
||||
{
|
||||
if (!seen.Add(toolboxName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cached = await this.OpenToolboxAsync(toolboxName, version: null, cancellationToken).ConfigureAwait(false);
|
||||
this._toolboxes[toolboxName] = cached;
|
||||
allTools.AddRange(cached.Tools);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Error))
|
||||
{
|
||||
this._logger.LogError(
|
||||
ex,
|
||||
"Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.",
|
||||
toolboxName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.Tools = allTools;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the tools for a per-request toolbox marker. Returns cached tools when the
|
||||
/// toolbox has already been opened; otherwise honors
|
||||
/// <see cref="FoundryToolboxOptions.StrictMode"/> to either reject or lazily open it.
|
||||
/// </summary>
|
||||
/// <param name="toolboxName">The Foundry toolbox name from the marker.</param>
|
||||
/// <param name="version">
|
||||
/// Optional pinned version. Currently reserved for future use — version-specific routing is
|
||||
/// handled server-side by the Foundry proxy. This parameter is accepted for forward compatibility
|
||||
/// but does not affect the proxy URL used to connect to the toolbox.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The request cancellation token.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the toolbox is not pre-registered and <see cref="FoundryToolboxOptions.StrictMode"/>
|
||||
/// is <see langword="true"/>, or when the toolbox endpoint is not configured.
|
||||
/// </exception>
|
||||
public async ValueTask<IReadOnlyList<AITool>> GetToolboxToolsAsync(
|
||||
string toolboxName,
|
||||
string? version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(toolboxName);
|
||||
|
||||
if (this._toolboxes.TryGetValue(toolboxName, out var cached))
|
||||
{
|
||||
return cached.Tools;
|
||||
}
|
||||
|
||||
if (this._options.StrictMode)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Toolbox '{toolboxName}' is not pre-registered via AddFoundryToolboxes(...). " +
|
||||
$"Either register it at startup or set {nameof(FoundryToolboxOptions.StrictMode)}=false to allow lazy resolution.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(this._resolvedEndpoint))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set.");
|
||||
}
|
||||
|
||||
await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Double-check after acquiring the lock to avoid duplicate opens under concurrency.
|
||||
if (this._toolboxes.TryGetValue(toolboxName, out cached))
|
||||
{
|
||||
return cached.Tools;
|
||||
}
|
||||
|
||||
cached = await this.OpenToolboxAsync(toolboxName, version, cancellationToken).ConfigureAwait(false);
|
||||
this._toolboxes[toolboxName] = cached;
|
||||
return cached.Tools;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._lazyOpenLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CachedToolbox> OpenToolboxAsync(
|
||||
string toolboxName,
|
||||
string? version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
this._logger.LogInformation("Connecting to toolbox '{ToolboxName}' at {ProxyUrl}.", toolboxName, proxyUrl);
|
||||
}
|
||||
|
||||
var handler = new FoundryToolboxBearerTokenHandler(this._credential, this._featuresHeader)
|
||||
{
|
||||
InnerHandler = new HttpClientHandler()
|
||||
};
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var transportOptions = new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(proxyUrl),
|
||||
Name = toolboxName,
|
||||
};
|
||||
|
||||
var transport = new HttpClientTransport(transportOptions, httpClient);
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
ClientInfo = new()
|
||||
{
|
||||
Name = this._agentName,
|
||||
Version = this._agentVersion
|
||||
}
|
||||
};
|
||||
|
||||
var client = await McpClient.CreateAsync(
|
||||
transport,
|
||||
clientOptions,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var mcpTools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"Toolbox '{ToolboxName}': discovered {ToolCount} tool(s).",
|
||||
toolboxName,
|
||||
mcpTools.Count);
|
||||
}
|
||||
|
||||
var wrapped = new List<AITool>(mcpTools.Count);
|
||||
foreach (var tool in mcpTools)
|
||||
{
|
||||
wrapped.Add(new ConsentAwareMcpClientAIFunction(tool, toolboxName));
|
||||
}
|
||||
|
||||
_ = version; // reserved for future version-specific routing; currently handled server-side by the proxy.
|
||||
|
||||
return new CachedToolbox(client, httpClient, wrapped);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var cached in this._toolboxes.Values)
|
||||
{
|
||||
await cached.Client.DisposeAsync().ConfigureAwait(false);
|
||||
cached.HttpClient.Dispose();
|
||||
}
|
||||
|
||||
this._toolboxes.Clear();
|
||||
this._lazyOpenLock.Dispose();
|
||||
}
|
||||
|
||||
private sealed record CachedToolbox(McpClient Client, HttpClient HttpClient, IReadOnlyList<AITool> Tools);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an in-memory implementation of <see cref="AgentSessionStore"/> for development and testing scenarios.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This implementation stores sessions in memory using a concurrent dictionary and is suitable for:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Single-instance development scenarios</description></item>
|
||||
/// <item><description>Testing and prototyping</description></item>
|
||||
/// <item><description>Scenarios where session persistence across restarts is not required</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Warning:</strong> All stored sessions will be lost when the application restarts.
|
||||
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
|
||||
/// such as Redis, SQL Server, or Azure Cosmos DB.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, JsonElement> _sessions = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(conversationId, agent.Id);
|
||||
this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(conversationId, agent.Id);
|
||||
JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null;
|
||||
|
||||
return sessionContent switch
|
||||
{
|
||||
null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false),
|
||||
_ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false),
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}";
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Converts Responses Server SDK input types to agent-framework <see cref="ChatMessage"/> types.
|
||||
/// </summary>
|
||||
internal static class InputConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the SDK <see cref="CreateResponse"/> request input items into a list of <see cref="ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request from the SDK.</param>
|
||||
/// <returns>A list of chat messages representing the request input.</returns>
|
||||
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in request.GetInputExpanded())
|
||||
{
|
||||
var message = ConvertInputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts resolved SDK <see cref="Item"/> input items into <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="items">The resolved input items from the SDK context.</param>
|
||||
/// <returns>A list of chat messages.</returns>
|
||||
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var message = ConvertInputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts resolved SDK <see cref="OutputItem"/> history/input items into <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="items">The resolved output items from the SDK context.</param>
|
||||
/// <returns>A list of chat messages.</returns>
|
||||
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var message = ConvertOutputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="ChatOptions"/> from the SDK request properties.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request.</param>
|
||||
/// <returns>A configured <see cref="ChatOptions"/> instance.</returns>
|
||||
public static ChatOptions ConvertToChatOptions(CreateResponse request)
|
||||
{
|
||||
return new ChatOptions
|
||||
{
|
||||
Temperature = (float?)request.Temperature,
|
||||
TopP = (float?)request.TopP,
|
||||
MaxOutputTokens = (int?)request.MaxOutputTokens,
|
||||
// Note: We intentionally do NOT set ModelId from request.Model here.
|
||||
// The hosted agent already has its own model configured, and passing
|
||||
// the client-provided model would override it (causing failures when
|
||||
// clients send placeholder values like "hosted-agent").
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts any Foundry Toolbox markers (<c>foundry-toolbox://</c>) from the request's
|
||||
/// MCP tool entries so the handler can resolve them server-side.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request.</param>
|
||||
/// <returns>A list of (name, optional version) pairs, one per detected marker. Never <see langword="null"/>.</returns>
|
||||
public static List<(string Name, string? Version)> ReadMcpToolboxMarkers(CreateResponse request)
|
||||
{
|
||||
var markers = new List<(string Name, string? Version)>();
|
||||
|
||||
if (request.Tools is null)
|
||||
{
|
||||
return markers;
|
||||
}
|
||||
|
||||
foreach (var tool in request.Tools)
|
||||
{
|
||||
if (tool is not MCPTool mcp || mcp.ServerUrl is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HostedMcpToolboxAITool.TryParseToolboxAddress(mcp.ServerUrl.ToString(), out var name, out var version))
|
||||
{
|
||||
markers.Add((name!, version));
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertInputItemToMessage(Item item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
ItemMessage msg => ConvertItemMessage(msg),
|
||||
FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput),
|
||||
ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall),
|
||||
ItemReferenceParam => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertItemMessage(ItemMessage msg)
|
||||
{
|
||||
var role = ConvertMessageRole(msg.Role);
|
||||
var contents = new List<AIContent>();
|
||||
|
||||
foreach (var content in msg.GetContentExpanded())
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case MessageContentInputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case MessageContentInputImageContent imageContent:
|
||||
if (imageContent.ImageUrl is not null)
|
||||
{
|
||||
var url = imageContent.ImageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(imageContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(imageContent.FileId));
|
||||
}
|
||||
|
||||
break;
|
||||
case MessageContentInputFileContent fileContent:
|
||||
if (fileContent.FileUrl is not null)
|
||||
{
|
||||
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileData))
|
||||
{
|
||||
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileContent.FileId));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.Filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contents.Count == 0)
|
||||
{
|
||||
contents.Add(new MeaiTextContent(string.Empty));
|
||||
}
|
||||
|
||||
return new ChatMessage(role, contents);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput)
|
||||
{
|
||||
var output = funcOutput.Output?.ToString() ?? string.Empty;
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
[new FunctionResultContent(funcOutput.CallId, output)]);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK input.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK input.")]
|
||||
private static ChatMessage ConvertItemFunctionToolCall(ItemFunctionToolCall funcCall)
|
||||
{
|
||||
IDictionary<string, object?>? arguments = null;
|
||||
if (funcCall.Arguments is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
arguments = JsonSerializer.Deserialize<Dictionary<string, object?>>(funcCall.Arguments);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
arguments = new Dictionary<string, object?> { ["_raw"] = funcCall.Arguments };
|
||||
}
|
||||
}
|
||||
|
||||
return new ChatMessage(
|
||||
ChatRole.Assistant,
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
|
||||
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
|
||||
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
|
||||
OutputItemReasoningItem => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertOutputItemMessageToChat(OutputItemMessage msg)
|
||||
{
|
||||
var role = ConvertMessageRole(msg.Role);
|
||||
var contents = new List<AIContent>();
|
||||
|
||||
foreach (var content in msg.Content)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case MessageContentInputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case MessageContentOutputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case MessageContentRefusalContent refusal:
|
||||
contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]"));
|
||||
break;
|
||||
case MessageContentInputImageContent imageContent:
|
||||
if (imageContent.ImageUrl is not null)
|
||||
{
|
||||
var url = imageContent.ImageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(imageContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(imageContent.FileId));
|
||||
}
|
||||
|
||||
break;
|
||||
case MessageContentInputFileContent fileContent:
|
||||
if (fileContent.FileUrl is not null)
|
||||
{
|
||||
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileData))
|
||||
{
|
||||
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileContent.FileId));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.Filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contents.Count == 0)
|
||||
{
|
||||
contents.Add(new MeaiTextContent(string.Empty));
|
||||
}
|
||||
|
||||
return new ChatMessage(role, contents);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")]
|
||||
private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall)
|
||||
{
|
||||
IDictionary<string, object?>? arguments = null;
|
||||
if (funcCall.Arguments is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
arguments = JsonSerializer.Deserialize<Dictionary<string, object?>>(funcCall.Arguments);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
arguments = new Dictionary<string, object?> { ["_raw"] = funcCall.Arguments };
|
||||
}
|
||||
}
|
||||
|
||||
return new ChatMessage(
|
||||
ChatRole.Assistant,
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
|
||||
{
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
[new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]);
|
||||
}
|
||||
|
||||
private static ChatRole ConvertMessageRole(MessageRole role)
|
||||
{
|
||||
return role switch
|
||||
{
|
||||
MessageRole.User => ChatRole.User,
|
||||
MessageRole.Assistant => ChatRole.Assistant,
|
||||
MessageRole.System => ChatRole.System,
|
||||
MessageRole.Developer => new ChatRole("developer"),
|
||||
_ => ChatRole.User
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Carries OAuth consent information for a single tool call that returned JSON-RPC error -32006.
|
||||
/// </summary>
|
||||
/// <param name="ToolboxName">The toolbox name that owns the tool.</param>
|
||||
/// <param name="ToolName">Fully-qualified tool name (e.g., <c>logicapps.send_email</c>).</param>
|
||||
/// <param name="ConsentUrl">The OAuth consent URL the user must visit.</param>
|
||||
internal sealed record McpConsentInfo(string ToolboxName, string ToolName, string ConsentUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Per-request mutable state shared between <see cref="ConsentAwareMcpClientAIFunction"/> (child context)
|
||||
/// and <see cref="AgentFrameworkResponseHandler"/> (parent context) via <see cref="McpConsentContext.Current"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Because <see cref="AsyncLocal{T}"/> only flows values DOWN from parent to children,
|
||||
/// we use a shared reference type so children can mutate it and the parent observes the mutations.
|
||||
/// </remarks>
|
||||
internal sealed class RequestConsentState
|
||||
{
|
||||
/// <summary>Consent information set by the tool wrapper when -32006 is detected.</summary>
|
||||
internal McpConsentInfo? Pending { get; set; }
|
||||
|
||||
/// <summary>The linked CTS to cancel when consent is required.</summary>
|
||||
internal CancellationTokenSource? CancellationSource { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Async-local context that enables <see cref="ConsentAwareMcpClientAIFunction"/>
|
||||
/// to signal a consent error back to <see cref="AgentFrameworkResponseHandler"/> through the
|
||||
/// <see cref="FunctionInvokingChatClient"/> tool loop. Flows with the async ExecutionContext.
|
||||
/// </summary>
|
||||
internal static class McpConsentContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Holds the shared <see cref="RequestConsentState"/> for the current request.
|
||||
/// Set once by the handler; read and mutated by the tool wrapper.
|
||||
/// </summary>
|
||||
internal static readonly AsyncLocal<RequestConsentState?> Current = new();
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Foundry.Hosting</RootNamespace>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<Title>Microsoft Agent Framework for Foundry Hosted Agents</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for hosting Foundry Agents with the Azure AI Agent Service.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectSharedRedaction>true</InjectSharedRedaction>
|
||||
<NoWarn>$(NoWarn);OPENAI001;MEAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- Disable package validation baseline until the first release -->
|
||||
<PropertyGroup>
|
||||
<PackageValidationBaselineVersion />
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,349 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Converts agent-framework <see cref="AgentResponseUpdate"/> streams into
|
||||
/// Responses Server SDK <see cref="ResponseStreamEvent"/> sequences using the
|
||||
/// <see cref="ResponseEventStream"/> builder pattern.
|
||||
/// </summary>
|
||||
internal static class OutputConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a stream of <see cref="AgentResponseUpdate"/> into a stream of
|
||||
/// <see cref="ResponseStreamEvent"/> using the SDK builder pattern.
|
||||
/// </summary>
|
||||
/// <param name="updates">The agent response updates to convert.</param>
|
||||
/// <param name="stream">The SDK event stream builder.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of SDK response stream events (excluding lifecycle events).</returns>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call arguments dictionary.")]
|
||||
public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEventsAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates,
|
||||
ResponseEventStream stream,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ResponseUsage? accumulatedUsage = null;
|
||||
OutputItemMessageBuilder? currentMessageBuilder = null;
|
||||
TextContentBuilder? currentTextBuilder = null;
|
||||
StringBuilder? accumulatedText = null;
|
||||
string? previousMessageId = null;
|
||||
bool hasTerminalEvent = false;
|
||||
var executorItemIds = new Dictionary<string, string>();
|
||||
|
||||
await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Handle workflow events from RawRepresentation
|
||||
if (update.RawRepresentation is WorkflowEvent workflowEvent)
|
||||
{
|
||||
// Close any open message builder before emitting workflow items
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
foreach (var evt in EmitWorkflowEvent(stream, workflowEvent, executorItemIds))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case MeaiTextContent textContent:
|
||||
{
|
||||
if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null)
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
}
|
||||
|
||||
previousMessageId = update.MessageId;
|
||||
|
||||
if (currentMessageBuilder is null)
|
||||
{
|
||||
currentMessageBuilder = stream.AddOutputItemMessage();
|
||||
yield return currentMessageBuilder.EmitAdded();
|
||||
|
||||
currentTextBuilder = currentMessageBuilder.AddTextContent();
|
||||
yield return currentTextBuilder.EmitAdded();
|
||||
|
||||
accumulatedText = new StringBuilder();
|
||||
}
|
||||
|
||||
if (textContent.Text is { Length: > 0 })
|
||||
{
|
||||
accumulatedText!.Append(textContent.Text);
|
||||
yield return currentTextBuilder!.EmitDelta(textContent.Text);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case FunctionCallContent funcCall:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
var callId = funcCall.CallId ?? Guid.NewGuid().ToString("N");
|
||||
var funcBuilder = stream.AddOutputItemFunctionCall(funcCall.Name, callId);
|
||||
yield return funcBuilder.EmitAdded();
|
||||
|
||||
var arguments = funcCall.Arguments is not null
|
||||
? JsonSerializer.Serialize(funcCall.Arguments)
|
||||
: "{}";
|
||||
|
||||
yield return funcBuilder.EmitArgumentsDelta(arguments);
|
||||
yield return funcBuilder.EmitArgumentsDone(arguments);
|
||||
yield return funcBuilder.EmitDone();
|
||||
break;
|
||||
}
|
||||
|
||||
case TextReasoningContent reasoningContent:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
var reasoningBuilder = stream.AddOutputItemReasoningItem();
|
||||
yield return reasoningBuilder.EmitAdded();
|
||||
|
||||
var summaryPart = reasoningBuilder.AddSummaryPart();
|
||||
yield return summaryPart.EmitAdded();
|
||||
|
||||
var text = reasoningContent.Text ?? string.Empty;
|
||||
yield return summaryPart.EmitTextDelta(text);
|
||||
yield return summaryPart.EmitTextDone(text);
|
||||
yield return summaryPart.EmitDone();
|
||||
|
||||
yield return reasoningBuilder.EmitDone();
|
||||
break;
|
||||
}
|
||||
|
||||
case UsageContent usageContent when usageContent.Details is not null:
|
||||
{
|
||||
accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage);
|
||||
break;
|
||||
}
|
||||
|
||||
case ErrorContent errorContent:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
hasTerminalEvent = true;
|
||||
|
||||
yield return stream.EmitFailed(
|
||||
ResponseErrorCode.ServerError,
|
||||
errorContent.Message ?? "An error occurred during agent execution.",
|
||||
accumulatedUsage);
|
||||
yield break;
|
||||
}
|
||||
|
||||
case DataContent:
|
||||
case UriContent:
|
||||
// Image/audio/file content from agents is not currently supported
|
||||
// as streaming output items in the Responses Server SDK builder pattern.
|
||||
// These would need to be serialized as base64 or URL references.
|
||||
break;
|
||||
|
||||
case FunctionResultContent:
|
||||
// Function results are internal to the agent's tool-calling loop
|
||||
// and are not emitted as output items in the response stream.
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining open message
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
if (!hasTerminalEvent)
|
||||
{
|
||||
yield return stream.EmitCompleted(accumulatedUsage);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ResponseStreamEvent> CloseCurrentMessage(
|
||||
OutputItemMessageBuilder? messageBuilder,
|
||||
TextContentBuilder? textBuilder,
|
||||
StringBuilder? accumulatedText)
|
||||
{
|
||||
if (messageBuilder is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (textBuilder is not null)
|
||||
{
|
||||
var finalText = accumulatedText?.ToString() ?? string.Empty;
|
||||
yield return textBuilder.EmitTextDone(finalText);
|
||||
yield return textBuilder.EmitDone();
|
||||
}
|
||||
|
||||
yield return messageBuilder.EmitDone();
|
||||
}
|
||||
|
||||
private static bool IsSameMessage(string? currentId, string? previousId) =>
|
||||
currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId;
|
||||
|
||||
private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? existing)
|
||||
{
|
||||
var inputTokens = details.InputTokenCount ?? 0;
|
||||
var outputTokens = details.OutputTokenCount ?? 0;
|
||||
var totalTokens = details.TotalTokenCount ?? 0;
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
inputTokens += existing.InputTokens;
|
||||
outputTokens += existing.OutputTokens;
|
||||
totalTokens += existing.TotalTokens;
|
||||
}
|
||||
|
||||
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
totalTokens: totalTokens);
|
||||
}
|
||||
|
||||
private static IEnumerable<ResponseStreamEvent> EmitWorkflowEvent(
|
||||
ResponseEventStream stream,
|
||||
WorkflowEvent workflowEvent,
|
||||
Dictionary<string, string> executorItemIds)
|
||||
{
|
||||
switch (workflowEvent)
|
||||
{
|
||||
case ExecutorInvokedEvent invokedEvent:
|
||||
{
|
||||
var itemId = GenerateItemId("wfa");
|
||||
executorItemIds[invokedEvent.ExecutorId] = itemId;
|
||||
|
||||
var item = new WorkflowActionOutputItem(
|
||||
kind: "InvokeExecutor",
|
||||
actionId: invokedEvent.ExecutorId,
|
||||
status: WorkflowActionOutputItemStatus.InProgress,
|
||||
id: itemId);
|
||||
|
||||
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
|
||||
yield return builder.EmitAdded(item);
|
||||
yield return builder.EmitDone(item);
|
||||
break;
|
||||
}
|
||||
|
||||
case ExecutorCompletedEvent completedEvent:
|
||||
{
|
||||
var itemId = GenerateItemId("wfa");
|
||||
|
||||
var item = new WorkflowActionOutputItem(
|
||||
kind: "InvokeExecutor",
|
||||
actionId: completedEvent.ExecutorId,
|
||||
status: WorkflowActionOutputItemStatus.Completed,
|
||||
id: itemId);
|
||||
|
||||
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
|
||||
yield return builder.EmitAdded(item);
|
||||
yield return builder.EmitDone(item);
|
||||
executorItemIds.Remove(completedEvent.ExecutorId);
|
||||
break;
|
||||
}
|
||||
|
||||
case ExecutorFailedEvent failedEvent:
|
||||
{
|
||||
var itemId = GenerateItemId("wfa");
|
||||
|
||||
var item = new WorkflowActionOutputItem(
|
||||
kind: "InvokeExecutor",
|
||||
actionId: failedEvent.ExecutorId,
|
||||
status: WorkflowActionOutputItemStatus.Failed,
|
||||
id: itemId);
|
||||
|
||||
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
|
||||
yield return builder.EmitAdded(item);
|
||||
yield return builder.EmitDone(item);
|
||||
executorItemIds.Remove(failedEvent.ExecutorId);
|
||||
break;
|
||||
}
|
||||
|
||||
// Informational/lifecycle events — no SDK output needed.
|
||||
// Note: AgentResponseUpdateEvent and WorkflowErrorEvent are unwrapped by
|
||||
// WorkflowSession.InvokeStageAsync() into regular AgentResponseUpdate objects
|
||||
// with populated Contents (TextContent, ErrorContent, etc.), so they flow
|
||||
// through the normal content processing path above — not through this method.
|
||||
case SuperStepStartedEvent:
|
||||
case SuperStepCompletedEvent:
|
||||
case WorkflowStartedEvent:
|
||||
case WorkflowWarningEvent:
|
||||
case RequestInfoEvent:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a valid item ID matching the SDK's <c>{prefix}_{50chars}</c> format.
|
||||
/// </summary>
|
||||
private static string GenerateItemId(string prefix)
|
||||
{
|
||||
// SDK format: {prefix}_{50 char body}
|
||||
var bytes = RandomNumberGenerator.GetBytes(25);
|
||||
var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase
|
||||
return $"{prefix}_{body}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering agent-framework agents as Foundry Hosted Agents
|
||||
/// using the Azure AI Responses Server SDK.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryHostingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the Azure AI Responses Server SDK and <see cref="AgentFrameworkResponseHandler"/>
|
||||
/// as the <see cref="ResponseHandler"/>. Agents are resolved from keyed DI services
|
||||
/// using the <c>agent.name</c> or <c>metadata["entity_id"]</c> from incoming requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method calls <c>AddResponsesServer()</c> internally, so you do not need to
|
||||
/// call it separately. Register your <see cref="AIAgent"/> instances before calling this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.AddAIAgent("my-agent", ...);
|
||||
/// builder.Services.AddFoundryResponses();
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
/// app.MapFoundryResponses();
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryResponses(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddResponsesServer();
|
||||
services.TryAddSingleton<AgentSessionStore, InMemoryAgentSessionStore>();
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Azure AI Responses Server SDK and a specific <see cref="AIAgent"/>
|
||||
/// as the handler for all incoming requests, regardless of the <c>agent.name</c> in the request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Use this overload when hosting a single agent. The provided agent instance is
|
||||
/// registered as both a keyed service and the default <see cref="AIAgent"/>.
|
||||
/// This method calls <c>AddResponsesServer()</c> internally.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.Services.AddFoundryResponses(myAgent);
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
/// app.MapFoundryResponses();
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="agent">The agent instance to register.</param>
|
||||
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
services.AddResponsesServer();
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
{
|
||||
services.TryAddKeyedSingleton(agent.Name, agent);
|
||||
services.TryAddKeyedSingleton(agent.Name, agentSessionStore);
|
||||
}
|
||||
|
||||
// Also register as the default (non-keyed) agent so requests
|
||||
// without an agent name can resolve it (e.g., local dev tooling).
|
||||
services.TryAddSingleton(agent);
|
||||
services.TryAddSingleton(agentSessionStore);
|
||||
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolboxes
|
||||
/// MCP proxy at startup and provides MCP tools to <see cref="AgentFrameworkResponseHandler"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each string in <paramref name="toolboxNames"/> is a toolbox name registered in the Foundry
|
||||
/// project. The proxy URL per toolbox is constructed as:
|
||||
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview</c>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent, startup succeeds without error and
|
||||
/// no tools are loaded (the container remains healthy per spec §2).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.Services.AddFoundryToolboxes("my-toolbox", "another-toolbox");
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="toolboxNames">Names of the Foundry toolboxes to connect to.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryToolboxes(
|
||||
this IServiceCollection services,
|
||||
params string[] toolboxNames)
|
||||
=> services.AddFoundryToolboxes(configureOptions: null, toolboxNames);
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Foundry Toolbox service with additional options configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Callback to further configure <see cref="FoundryToolboxOptions"/> (e.g. set <see cref="FoundryToolboxOptions.StrictMode"/>).</param>
|
||||
/// <param name="toolboxNames">Names of the Foundry toolboxes to pre-register at startup.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryToolboxes(
|
||||
this IServiceCollection services,
|
||||
Action<FoundryToolboxOptions>? configureOptions,
|
||||
params string[] toolboxNames)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.Configure<FoundryToolboxOptions>(opt =>
|
||||
{
|
||||
foreach (var name in toolboxNames)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
opt.ToolboxNames.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
configureOptions?.Invoke(opt);
|
||||
});
|
||||
|
||||
// Register DefaultAzureCredential as the default TokenCredential if not already registered
|
||||
services.TryAddSingleton<TokenCredential>(_ => new DefaultAzureCredential());
|
||||
|
||||
// Register FoundryToolboxService as a singleton so it can be injected into the handler
|
||||
services.TryAddSingleton<FoundryToolboxService>();
|
||||
|
||||
// AddHostedService uses TryAddEnumerable internally, so calling AddFoundryToolboxes
|
||||
// multiple times will not invoke StartAsync twice on the same singleton.
|
||||
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <param name="prefix">Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses).</param>
|
||||
/// <returns>The endpoint route builder for chaining.</returns>
|
||||
public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuilder endpoints, string prefix = "")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
endpoints.MapResponsesServer(prefix);
|
||||
|
||||
if (endpoints is IApplicationBuilder app)
|
||||
{
|
||||
// Ensure the middleware is added to the pipeline
|
||||
app.UseMiddleware<AgentFrameworkUserAgentMiddleware>();
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ActivitySource name for the Responses hosting pipeline.
|
||||
/// Matches the value previously exposed by <c>AgentHostTelemetry.ResponsesSourceName</c>
|
||||
/// in <c>Azure.AI.AgentServer.Core</c>.
|
||||
/// </summary>
|
||||
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
|
||||
|
||||
/// <summary>
|
||||
/// Wraps <paramref name="agent"/> with <see cref="OpenTelemetryAgent"/> instrumentation
|
||||
/// so that agent invocations emit spans into the pipeline registered by
|
||||
/// <c>Azure.AI.AgentServer.Core</c>'s <c>AddAgentHostTelemetry()</c>.
|
||||
/// If the agent is already instrumented the original instance is returned unchanged.
|
||||
/// </summary>
|
||||
internal static AIAgent ApplyOpenTelemetry(AIAgent agent)
|
||||
{
|
||||
if (agent.GetService<OpenTelemetryAgent>() is not null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
return agent.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: ResponsesSourceName)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next)
|
||||
{
|
||||
private static readonly string s_userAgentValue = CreateUserAgentValue();
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var headers = context.Request.Headers;
|
||||
var userAgent = headers.UserAgent.ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(userAgent))
|
||||
{
|
||||
headers.UserAgent = s_userAgentValue;
|
||||
}
|
||||
else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
headers.UserAgent = $"{userAgent} {s_userAgentValue}";
|
||||
}
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string CreateUserAgentValue()
|
||||
{
|
||||
const string Name = "agent-framework-dotnet";
|
||||
|
||||
if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
/// Creates a non-versioned <see cref="ChatClientAgent"/> backed by the project's Responses API using the specified options.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The <see cref="AIProjectClient"/> to use for Responses API calls. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="options">Configuration options that control the agent's behavior. <see cref="ChatOptions.ModelId"/> is required.</param>
|
||||
/// <param name="options">Optional configuration options that control the agent's behavior.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for creating loggers used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
@@ -190,15 +190,14 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="options"/> does not specify <see cref="ChatOptions.ModelId"/>.</exception>
|
||||
public static ChatClientAgent AsAIAgent(
|
||||
this AIProjectClient aiProjectClient,
|
||||
ChatClientAgentOptions options,
|
||||
ChatClientAgentOptions? options = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(options);
|
||||
|
||||
return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services);
|
||||
return CreateResponsesChatClientAgent(aiProjectClient, options ?? new(), clientFactory, loggerFactory, services);
|
||||
}
|
||||
|
||||
#region Private
|
||||
|
||||
@@ -112,6 +112,16 @@ public static class FoundryAITool
|
||||
public static AITool CreateA2ATool(Uri baseUri, string? agentCardPath = null)
|
||||
=> ProjectsAgentTool.CreateA2ATool(baseUri, agentCardPath).AsAITool();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="AITool"/> marker that references a Foundry Toolbox by name so
|
||||
/// the hosted server side can resolve and expose its MCP tools for a single request.
|
||||
/// </summary>
|
||||
/// <param name="toolboxName">The Foundry toolbox name.</param>
|
||||
/// <param name="version">Optional pinned toolbox version. When <see langword="null"/>, the project's default version is used.</param>
|
||||
/// <returns>An <see cref="AITool"/> marker backed by <see cref="HostedMcpToolboxAITool"/>.</returns>
|
||||
public static AITool CreateHostedMcpToolbox(string toolboxName, string? version = null)
|
||||
=> new HostedMcpToolboxAITool(toolboxName, version);
|
||||
|
||||
// --- OpenAI SDK ResponseTool factories ---
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// A marker <see cref="HostedMcpServerTool"/> that identifies a Foundry Toolbox by name
|
||||
/// (and optional version) on the OpenAI Responses <c>mcp</c> wire format.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The hosted server recognizes this marker by its <see cref="HostedMcpServerTool.ServerAddress"/>
|
||||
/// scheme (<see cref="UriScheme"/>) and resolves it to the set of MCP tools exposed by the
|
||||
/// matching toolbox registered in the Foundry project.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Callers should not construct this type directly. Use one of the
|
||||
/// <c>FoundryAITool.CreateHostedMcpToolbox(...)</c> factory overloads.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class HostedMcpToolboxAITool : HostedMcpServerTool
|
||||
{
|
||||
/// <summary>
|
||||
/// The URI scheme used to identify Foundry Toolbox markers on the wire.
|
||||
/// </summary>
|
||||
public const string UriScheme = "foundry-toolbox";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HostedMcpToolboxAITool"/> class.
|
||||
/// </summary>
|
||||
/// <param name="toolboxName">The Foundry toolbox name.</param>
|
||||
/// <param name="version">
|
||||
/// Optional pinned toolbox version. When <see langword="null"/>, the project's default version is used.
|
||||
/// Currently reserved for forward compatibility — version-specific routing is handled server-side by
|
||||
/// the Foundry proxy.
|
||||
/// </param>
|
||||
public HostedMcpToolboxAITool(string toolboxName, string? version = null)
|
||||
: base(
|
||||
serverName: NotNullOrWhitespace(toolboxName, nameof(toolboxName)),
|
||||
serverAddress: BuildAddress(toolboxName, version))
|
||||
{
|
||||
this.ToolboxName = toolboxName;
|
||||
this.Version = version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Foundry toolbox name.
|
||||
/// </summary>
|
||||
public string ToolboxName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pinned toolbox version, or <see langword="null"/> to use the project's default.
|
||||
/// </summary>
|
||||
public string? Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds the toolbox marker address: <c>foundry-toolbox://{name}[?version={v}]</c>.
|
||||
/// </summary>
|
||||
public static string BuildAddress(string toolboxName, string? version)
|
||||
{
|
||||
_ = NotNullOrWhitespace(toolboxName, nameof(toolboxName));
|
||||
|
||||
return string.IsNullOrEmpty(version)
|
||||
? $"{UriScheme}://{toolboxName}"
|
||||
: $"{UriScheme}://{toolboxName}?version={Uri.EscapeDataString(version)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a toolbox marker address into its name and optional version components.
|
||||
/// </summary>
|
||||
/// <param name="address">The <see cref="HostedMcpServerTool.ServerAddress"/> to inspect.</param>
|
||||
/// <param name="toolboxName">When this method returns <see langword="true"/>, the parsed toolbox name.</param>
|
||||
/// <param name="version">When this method returns <see langword="true"/>, the optional version, or <see langword="null"/>.</param>
|
||||
/// <returns><see langword="true"/> if <paramref name="address"/> is a Foundry toolbox marker; otherwise <see langword="false"/>.</returns>
|
||||
public static bool TryParseToolboxAddress(
|
||||
string? address,
|
||||
[NotNullWhen(true)] out string? toolboxName,
|
||||
out string? version)
|
||||
{
|
||||
toolboxName = null;
|
||||
version = null;
|
||||
|
||||
if (string.IsNullOrEmpty(address))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(address, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, UriScheme, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// For foundry-toolbox://name, the name appears as Authority (host) with an empty path.
|
||||
// For foundry-toolbox:name (rare), it falls through to PathAndQuery.
|
||||
var name = uri.Host;
|
||||
if (string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(uri.AbsolutePath))
|
||||
{
|
||||
name = uri.AbsolutePath.TrimStart('/');
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
toolboxName = name;
|
||||
|
||||
var query = uri.Query;
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
// Minimal parser to avoid a HttpUtility dependency on netstandard.
|
||||
foreach (var part in query.TrimStart('?').Split('&'))
|
||||
{
|
||||
var eq = part.IndexOf('=');
|
||||
if (eq <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = part.Substring(0, eq);
|
||||
if (string.Equals(key, "version", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
version = Uri.UnescapeDataString(part.Substring(eq + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NotNullOrWhitespace(string value, string paramName)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
throw new ArgumentNullException(paramName);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be empty or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Extensions.Compliance.Abstractions" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
|
||||
Reference in New Issue
Block a user