Compare commits

..
Author SHA1 Message Date
alliscodeandCopilot 069a1039f8 Add Foundry.Hosting to release solution filter
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 12:37:54 -07:00
alliscodeandCopilot 8fc2117eb7 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>
2026-04-21 12:31:51 -07:00
alliscodeandCopilot 22a2fb15ef 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>
2026-04-21 12:16:04 -07:00
alliscodeandCopilot a01e3f14b2 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>
2026-04-21 11:56:41 -07:00
alliscodeandCopilot a828f3463c Merge main into feature/responses-hosting - resolve conflicts
Resolved 3 conflicts (keep both sides):
- Directory.Packages.props: keep higher OpenTelemetry versions (1.15.0/1.14.0)
- agent-framework-dotnet.slnx: keep both Evaluation folder and FoundryHostedAgents entries
- Foundry.csproj: keep both Evaluation support and AgentServer.Responses packages

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 09:50:03 -07:00
4a7f04157a 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>
2026-04-21 09:34:45 -07:00
f062ed4f63 .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>
2026-04-21 08:43:55 -07:00
a14b7d8f8a .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>
2026-04-20 16:54:44 -07:00
3f6716a8db .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>
2026-04-20 15:37:59 -07:00
d30cd927b0 .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>
2026-04-20 12:13:40 -07:00
3c48ca17c8 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>
2026-04-20 10:25:27 -07:00
Roger BarretoandGitHub ecc34ff902 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.
2026-04-17 22:39:48 +01:00
Roger BarretoandGitHub 31acaccc50 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.
2026-04-17 18:39:17 +01:00
Roger BarretoandGitHub e737ac6a44 .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
2026-04-16 21:10:46 +01:00
Roger Barreto 8f5a6ec4a1 Fix AgentFrameworkResponseHandlerTests: implement session methods in test mock agents 2026-04-16 13:35:10 +01:00
Roger Barreto 518e90e114 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
2026-04-16 13:05:47 +01:00
Roger Barreto 7c9ed7f5d6 Remove FoundryResponsesRepl and empty FoundryResponsesHosting directory 2026-04-16 12:44:41 +01:00
Roger Barreto 77529dc3ea Rename Hosted-Workflows to Hosted-Workflow-Simple, Hosted-WorkflowHandoff to Hosted-Workflow-Handoff 2026-04-16 12:43:03 +01:00
Roger Barreto e6dfdd46f2 Move FoundryResponsesHosting to responses/Hosted-WorkflowHandoff, use GetResponsesClient 2026-04-16 12:38:29 +01:00
Roger Barreto 10077bdb13 Use placeholder for AGENT_NAME in Hosted-FoundryAgent .env.example 2026-04-16 12:02:51 +01:00
Roger Barreto 98d3a81ed4 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
2026-04-16 11:42:59 +01:00
Roger Barreto d49d546bb4 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
2026-04-16 11:21:51 +01:00
Roger Barreto 030ef3a115 Merge branch 'feature/responses-hosting' of https://github.com/microsoft/agent-framework into hosted-agent-adapter
# Conflicts:
#	dotnet/nuget/nuget-package.props
#	dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-ChatClientAgent/Program.cs
#	dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-FoundryAgent/Program.cs
#	dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-LocalTools/Program.cs
#	dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-McpTools/Program.cs
#	dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Workflows/Program.cs
#	dotnet/samples/04-hosting/FoundryResponsesHosting/Program.cs
#	dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs
#	dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs
#	dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs
#	dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs
#	dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs
2026-04-16 09:54:54 +01:00
Roger BarretoandGitHub 53fec2a989 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.
2026-04-16 09:43:13 +01:00
alliscodeandCopilot 93306f5f5b 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>
2026-04-15 18:05:12 -07:00
alliscodeandCopilot bc303d87f4 Fix dotnet format: add Async suffix to test methods (IDE1006), fix encoding and style
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 17:45:22 -07:00
alliscodeandCopilot 8d30cadc83 Fix CHARSET: add UTF-8 BOM to Hosted-LocalTools and Hosted-Workflows
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 17:20:15 -07:00
alliscodeandCopilot c1ea834e1f Fix IDE0009: add 'this' qualification in all HostedAgentsV2 samples
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 17:14:01 -07:00
alliscodeandCopilot e751779da4 Fix IDE0009: add 'this' qualification in DevTemporaryTokenCredential
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 17:05:22 -07:00
alliscode 5af3d47062 Fixing small issues. 2026-04-15 16:57:42 -07:00
alliscodeandCopilot 4739d1ec9c 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>
2026-04-15 16:49:27 -07:00
Roger Barretoandalliscode 930d47789a 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.
2026-04-15 16:49:25 -07:00
Roger Barretoandalliscode 8b49a0a207 Removing extra using samples 2026-04-15 16:49:25 -07:00
Roger Barretoandalliscode e70f20622a Adding LocalTools + Workflow samples 2026-04-15 16:49:24 -07:00
Roger Barretoandalliscode 0949260d16 Version bump 2026-04-15 16:49:18 -07:00
Roger Barretoandalliscode 28f3d178e1 Address text rag sample working 2026-04-15 16:49:17 -07:00
Roger Barretoandalliscode ebb3483a2c Foundry Agent Hosting 2026-04-15 16:49:17 -07:00
Roger Barretoandalliscode 9e820aaad7 Update chat client agent for contributor and devs 2026-04-15 16:49:16 -07:00
Roger Barretoandalliscode a4519045a4 Using updates 2026-04-15 16:49:15 -07:00
Roger Barretoandalliscode ac6a0e3d95 Adding SessionStorage and SessionManagement, improving samples to align Consumption vs Hosting 2026-04-15 16:49:15 -07:00
Roger Barretoandalliscode 990330e198 ChatClientAgent working 2026-04-15 16:49:14 -07:00
Roger Barretoandalliscode 3ef5436537 Hosting Samples update 2026-04-15 16:49:13 -07:00
Roger Barretoandalliscode 3984ef4ef1 Hosting Samples update 2026-04-15 16:49:12 -07:00
Roger Barretoandalliscode bf779f2029 Hosting Samples update 2026-04-15 16:49:11 -07:00
Roger Barretoandalliscode d0fc7499b7 Hosting Samples update 2026-04-15 16:49:10 -07:00
Roger Barretoandalliscode 3a2617997e Add UserAgent middleware tests for Foundry hosting 2026-04-15 16:49:09 -07:00
Roger Barretoandalliscode cf4a3f6914 Bump hosted internal package version 2026-04-15 16:46:14 -07:00
Roger Barretoandalliscode 34eeea165d Address breaking changes in 260408 2026-04-15 16:45:53 -07:00
6e834fcac2 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>
2026-04-15 16:45:52 -07:00
alliscodeandCopilot a25502b7c8 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>
2026-04-15 16:45:46 -07:00
alliscodeandCopilot afce5665e2 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>
2026-04-15 16:43:07 -07:00
alliscodeandCopilot eccdcffc6c Fix CA1873: guard LogWarning with IsEnabled check
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 16:42:26 -07:00
alliscodeandCopilot 226abebac2 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>
2026-04-15 16:42:26 -07:00
alliscodeandCopilot 4a2e25573f Bump package version to 0.9.0-hosted.260402.2
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 16:40:49 -07:00
alliscodeandCopilot 838fd7f4d0 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>
2026-04-15 16:40:09 -07:00
alliscodeandCopilot 4dd3a30875 Add hosted package version suffix (0.9.0-hosted) to distinguish from mainline
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 16:39:45 -07:00
alliscodeandCopilot 5368e530a8 Update Azure.AI.AgentServer packages to 1.0.0-alpha.20260401.5
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 16:39:19 -07:00
alliscodeandCopilot 15e36efe39 Clean up tests and sample formatting
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 16:39:18 -07:00
alliscodeandCopilot e5c1203f4d Bump System.ClientModel to 1.10.0 for Azure.Core 1.52.0 compat
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 16:39:18 -07:00
alliscodeandCopilot 12251b1e69 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>
2026-04-15 16:39:17 -07:00
548dfb10b6 .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>
2026-04-15 16:34:28 -07:00
Roger Barreto 5b16684930 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.
2026-04-15 15:11:05 +01:00
Roger Barreto 1584879edd Removing extra using samples 2026-04-13 19:50:11 +01:00
Roger Barreto 4b9d848541 Adding LocalTools + Workflow samples 2026-04-13 16:59:18 +01:00
Roger Barreto d02cee5353 Version bump 2026-04-13 16:30:13 +01:00
Roger Barreto ad625602a3 Address text rag sample working 2026-04-13 16:20:18 +01:00
Roger Barreto 340ec82623 Foundry Agent Hosting 2026-04-13 15:29:36 +01:00
Roger Barreto 9e842e1821 Update chat client agent for contributor and devs 2026-04-13 15:11:26 +01:00
Roger Barreto 4895ad74e1 Using updates 2026-04-13 11:21:17 +01:00
Roger Barreto fcc96823a4 Adding SessionStorage and SessionManagement, improving samples to align Consumption vs Hosting 2026-04-11 17:45:52 +01:00
Roger Barreto a82c1ede09 ChatClientAgent working 2026-04-11 13:03:55 +01:00
Roger Barreto eb7f8617c1 Hosting Samples update 2026-04-11 12:05:44 +01:00
Roger Barreto 026f71ac32 Hosting Samples update 2026-04-11 11:36:00 +01:00
Roger Barreto 4ab43734c6 Hosting Samples update 2026-04-11 11:35:53 +01:00
Roger Barreto 7407c0a35e Hosting Samples update 2026-04-11 10:57:16 +01:00
Roger Barreto fed3c06817 Add UserAgent middleware tests for Foundry hosting 2026-04-09 15:40:54 +01:00
Roger Barreto 176b8f9b34 Bump hosted internal package version 2026-04-09 09:46:10 +01:00
Roger Barreto 53dd990185 Address breaking changes in 260408 2026-04-09 09:30:43 +01:00
23db9569ce 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>
2026-04-03 16:35:44 -07:00
alliscodeandCopilot eb67379033 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>
2026-04-03 11:17:21 -07:00
alliscodeandCopilot 21dc5311be 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>
2026-04-03 08:00:42 -07:00
alliscodeandCopilot 3b03c6dd55 Fix CA1873: guard LogWarning with IsEnabled check
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-03 08:00:41 -07:00
alliscodeandCopilot 5ba8521ce2 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>
2026-04-03 08:00:39 -07:00
alliscodeandCopilot af9807b4a4 Bump package version to 0.9.0-hosted.260402.2
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-03 08:00:37 -07:00
alliscodeandCopilot a1c19b90cd 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>
2026-04-03 08:00:35 -07:00
alliscodeandCopilot bb7082d3c6 Add hosted package version suffix (0.9.0-hosted) to distinguish from mainline
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-03 08:00:33 -07:00
alliscodeandCopilot 59ad42912f Update Azure.AI.AgentServer packages to 1.0.0-alpha.20260401.5
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-03 08:00:32 -07:00
alliscodeandCopilot 52cc4a9f0e Clean up tests and sample formatting
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-03 08:00:31 -07:00
alliscodeandCopilot 69597d7d77 Bump System.ClientModel to 1.10.0 for Azure.Core 1.52.0 compat
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-03 08:00:30 -07:00
alliscodeandCopilot 0b1ed03cd0 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>
2026-04-03 08:00:28 -07:00
233 changed files with 3378 additions and 18678 deletions
-61
View File
@@ -1,61 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Resolve the issue author and check their team membership.
*
* @param {object} opts
* @param {object} opts.github - Octokit REST client from actions/github-script
* @param {object} opts.context - GitHub Actions context
* @param {object} opts.core - GitHub Actions core toolkit
* @param {string} opts.teamSlug - Team slug to check membership against
* @param {string|number} opts.issueNumber - Issue number to resolve author for
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
*/
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
let author = context.payload.issue?.user?.login;
if (!author) {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(issueNumber),
});
author = issue.user?.login;
}
if (!author) {
core.setFailed('Could not determine issue author (user may be deleted).');
return { author: null, isTeamMember: false };
}
try {
await github.rest.teams.getByName({
org: context.repo.owner,
team_slug: teamSlug,
});
} catch (error) {
core.setFailed(`Team lookup failed for ${teamSlug}: ${error.message}`);
throw error;
}
let isTeamMember = false;
try {
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: teamSlug,
username: author,
});
isTeamMember = teamMembership.data.state === 'active';
} catch (error) {
if (error.status === 404) {
core.info(`Author ${author} is not a member of team ${teamSlug}.`);
isTeamMember = false;
} else {
core.setFailed(`Team membership lookup failed for ${author}: ${error.message}`);
throw error;
}
}
return { author, isTeamMember };
}
module.exports = checkTeamMembership;
-178
View File
@@ -1,178 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Tests for check_team_membership.js.
*
* Run with: node --test .github/tests/test_check_team_membership.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const checkTeamMembership = require('../scripts/check_team_membership.js');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
const core = {
_infoMessages: [],
_failedMessages: [],
info(msg) { this._infoMessages.push(msg); },
setFailed(msg) { this._failedMessages.push(msg); },
};
const context = {
payload: { issue: payloadIssue },
repo: { owner: 'test-org', repo: 'test-repo' },
};
const github = {
rest: {
issues: {
get: async () => ({
data: { user: apiUser ? { login: apiUser } : null },
}),
},
teams: {
getByName: async () => ({}),
getMembershipForUserInOrg: async () => ({
data: { state: teamState },
}),
},
},
};
return { core, context, github };
}
const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' };
// ---------------------------------------------------------------------------
// Author resolution
// ---------------------------------------------------------------------------
describe('author resolution', () => {
it('resolves author from event payload', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'payload-user' } },
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'payload-user');
});
it('resolves author via API when payload issue is absent', async () => {
const { github, context, core } = createMocks({ apiUser: 'api-user' });
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'api-user');
});
it('resolves author via API when payload issue user is null (deleted account)', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: null },
apiUser: 'fetched-user',
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'fetched-user');
});
it('handles deleted account when API also returns null user', async () => {
const { github, context, core } = createMocks({ apiUser: null });
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, null);
assert.equal(result.isTeamMember, false);
assert.ok(core._failedMessages.some(m => m.includes('deleted')));
});
});
// ---------------------------------------------------------------------------
// Team lookup
// ---------------------------------------------------------------------------
describe('team lookup', () => {
it('fails the job when team lookup errors', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'user1' } },
});
const error = new Error('Bad credentials');
github.rest.teams.getByName = async () => { throw error; };
await assert.rejects(
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
(err) => err === error,
);
assert.ok(core._failedMessages.some(m => m.includes('Team lookup failed')));
});
});
// ---------------------------------------------------------------------------
// Team membership
// ---------------------------------------------------------------------------
describe('team membership', () => {
it('returns true for active team member', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'member' } },
teamState: 'active',
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.isTeamMember, true);
});
it('returns false for pending team member', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'pending-user' } },
teamState: 'pending',
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.isTeamMember, false);
});
it('treats 404 membership response as non-member without failing', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'outsider' } },
});
const notFoundError = new Error('Not Found');
notFoundError.status = 404;
github.rest.teams.getMembershipForUserInOrg = async () => { throw notFoundError; };
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.isTeamMember, false);
assert.equal(core._failedMessages.length, 0);
assert.ok(core._infoMessages.some(m => m.includes('not a member')));
});
it('fails the job on non-404 membership errors', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'user1' } },
});
const serverError = new Error('Internal Server Error');
serverError.status = 500;
github.rest.teams.getMembershipForUserInOrg = async () => { throw serverError; };
await assert.rejects(
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
(err) => err === serverError,
);
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
});
it('fails the job on membership errors without status code', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'user1' } },
});
const networkError = new Error('ECONNREFUSED');
github.rest.teams.getMembershipForUserInOrg = async () => { throw networkError; };
await assert.rejects(
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
(err) => err === networkError,
);
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
});
});
-165
View File
@@ -1,165 +0,0 @@
name: DevFlow PR Review
on:
pull_request_target:
types:
- opened
- reopened
- ready_for_review
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to review
required: true
type: string
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: devflow-pr-review-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}
cancel-in-progress: true
env:
DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }}
DEVFLOW_REF: main
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
DEVFLOW_PATH: ${{ github.workspace }}/devflow
jobs:
team_check:
runs-on: ubuntu-latest
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
pr_number: ${{ steps.pr.outputs.pr_number }}
pr_url: ${{ steps.pr.outputs.pr_url }}
repo: ${{ steps.pr.outputs.repo }}
steps:
- name: Resolve PR metadata
id: pr
shell: bash
env:
PR_HTML_URL: ${{ github.event.pull_request.html_url }}
PR_NUMBER_EVENT: ${{ github.event.pull_request.number }}
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
pr_number="${PR_NUMBER_EVENT}"
pr_url="${PR_HTML_URL}"
else
pr_number="${PR_NUMBER_INPUT}"
pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${pr_number}"
fi
if [[ ! "$pr_number" =~ ^[1-9][0-9]*$ ]]; then
echo "Could not determine PR number; for workflow_dispatch runs, the 'pr_number' input is required when not running on pull_request_target." >&2
exit 1
fi
echo "pr_url=${pr_url}" >> "$GITHUB_OUTPUT"
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Check PR author team membership
id: check
uses: actions/github-script@v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
let author = context.payload.pull_request?.user?.login;
if (!author) {
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(process.env.PR_NUMBER),
});
author = pr.user.login;
}
let isTeamMember = false;
try {
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: process.env.TEAM_NAME,
username: author,
});
isTeamMember = teamMembership.data.state === 'active';
} catch (error) {
console.log(`Team membership lookup failed for ${author}: ${error.message}`);
isTeamMember = false;
}
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
if (isTeamMember) {
core.info(`Author ${author} is a team member; proceeding with review.`);
} else {
core.info(`Author ${author} is not a member of ${process.env.TEAM_NAME}; skipping review.`);
}
review:
runs-on: ubuntu-latest
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'true' }}
timeout-minutes: 60
# Advisory check: failures here should not block the PR. The reviewer
# posts comments as a best-effort signal; if the pipeline breaks, the
# PR author should still be able to merge without a red required check.
continue-on-error: true
steps:
# Safe checkout: base repo only, not the untrusted PR head.
- name: Checkout target repo base
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
path: target-repo
# Private DevFlow checkout: the PAT/token grants access to this repo's code.
- name: Checkout DevFlow
uses: actions/checkout@v6
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
token: ${{ secrets.DEVFLOW_TOKEN }}
fetch-depth: 1
persist-credentials: false
path: devflow
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
version: "0.11.x"
enable-cache: true
- name: Install DevFlow dependencies
working-directory: ${{ env.DEVFLOW_PATH }}
run: uv sync --frozen
- name: Run PR review
id: review
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
PR_URL: ${{ needs.team_check.outputs.pr_url }}
run: |
uv run python scripts/trigger_pr_review.py \
--pr-url "$PR_URL" \
--github-username "$GITHUB_ACTOR" \
--no-require-comment-selection
-199
View File
@@ -1,199 +0,0 @@
name: Issue Triage
on:
workflow_dispatch:
inputs:
issue_number:
description: Issue number to triage
required: true
type: string
permissions:
contents: read
issues: write
id-token: write
concurrency:
group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }}
cancel-in-progress: true
env:
DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }}
DEVFLOW_REF: main
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
DEVFLOW_PATH: ${{ github.workspace }}/devflow
jobs:
team_check:
runs-on: ubuntu-latest
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
issue_number: ${{ steps.issue.outputs.issue_number }}
repo: ${{ steps.issue.outputs.repo }}
steps:
- name: Resolve issue metadata
id: issue
shell: bash
env:
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
issue_number="${ISSUE_NUMBER_EVENT}"
else
issue_number="${ISSUE_NUMBER_INPUT}"
fi
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2
exit 1
fi
echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT"
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Checkout scripts
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
persist-credentials: false
- name: Check issue author team membership
id: check
uses: actions/github-script@v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
const { author, isTeamMember } = await checkTeamMembership({
github,
context,
core,
teamSlug: process.env.TEAM_NAME,
issueNumber: process.env.ISSUE_NUMBER,
});
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
if (isTeamMember) {
core.info(`Author ${author} is a team member; skipping auto-triage.`);
} else {
core.info(`Author ${author} is not a team member; proceeding with triage.`);
}
triage:
runs-on: ubuntu-latest
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
environment: integration
timeout-minutes: 60
steps:
# Safe checkout: base repo only.
- name: Checkout target repo base
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
path: target-repo
# Private DevFlow (maf-dashboard) checkout.
- name: Checkout DevFlow
uses: actions/checkout@v6
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
token: ${{ secrets.DEVFLOW_TOKEN }}
fetch-depth: 1
persist-credentials: false
path: devflow
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
version: "0.11.x"
enable-cache: true
- name: Install DevFlow dependencies
working-directory: ${{ env.DEVFLOW_PATH }}
run: uv sync --frozen
- name: Azure CLI Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Classify issue relevance
id: spam
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
run: |
uv run python scripts/classify_issue_spam.py \
--repo "$ISSUE_REPO" \
--issue-number "$ISSUE_NUMBER" \
--repo-path "${TARGET_REPO_PATH}" \
--apply-labels
- name: Stop after spam gate
if: ${{ steps.spam.outputs.decision != 'allow' }}
shell: bash
env:
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
run: |
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
exit 1
- name: Reproduce reported issue
if: ${{ steps.spam.outputs.decision == 'allow' }}
id: repro
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
# Model-provider settings for generated repro code. Never enter the
# agent prompt; consumed by SDK constructors via os.environ. Azure
# OpenAI and Foundry auth via AAD from the azure/login step above.
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
run: |
uv run python scripts/trigger_issue_repro.py \
--repo "$ISSUE_REPO" \
--issue-number "$ISSUE_NUMBER" \
--github-username "$GITHUB_ACTOR"
+1 -114
View File
@@ -87,14 +87,6 @@ jobs:
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure OpenAI integration tests
python-tests-azure-openai:
@@ -138,14 +130,6 @@ jobs:
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Misc integration tests (Anthropic, Hyperlight, Ollama, MCP)
python-tests-misc-integration:
@@ -189,14 +173,6 @@ jobs:
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 30
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-misc
path: ./python/pytest.xml
if-no-files-found: ignore
- name: Stop local MCP server
if: always()
shell: bash
@@ -273,14 +249,6 @@ jobs:
-x
--timeout=360 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-functions
path: ./python/pytest.xml
if-no-files-found: ignore
# Foundry integration tests
python-tests-foundry:
@@ -327,14 +295,6 @@ jobs:
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-foundry
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure Cosmos integration tests
python-tests-cosmos:
@@ -379,80 +339,7 @@ jobs:
echo "Cosmos DB emulator did not become ready in time." >&2
exit 1
- name: Test with pytest (Cosmos integration)
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
if-no-files-found: ignore
# Flaky test trend report (aggregates per-job JUnit XML results)
python-flaky-test-report:
name: Flaky Test Report
if: >
always() &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs:
[
python-tests-openai,
python-tests-azure-openai,
python-tests-misc-integration,
python-tests-functions,
python-tests-foundry,
python-tests-cosmos,
]
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: test-results/
- name: Restore flaky report history cache
uses: actions/cache/restore@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-integration-${{ github.run_id }}
restore-keys: |
flaky-report-history-integration-
- name: Generate trend report
run: >
uv run python scripts/flaky_report/aggregate.py
../test-results/
flaky-report-history.json
flaky-test-report.md
- name: Post to Job Summary
if: always()
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save flaky report history cache
if: always()
uses: actions/cache/save@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-integration-${{ github.run_id }}
- name: Upload unified trend report
if: always()
uses: actions/upload-artifact@v7
with:
name: flaky-test-report
path: |
python/flaky-test-report.md
python/flaky-report-history.json
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
python-integration-tests-check:
if: always()
+1 -110
View File
@@ -181,13 +181,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: OpenAI integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure OpenAI integration tests
python-tests-azure-openai:
@@ -251,13 +244,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Azure OpenAI integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Misc integration tests (Anthropic, Ollama, MCP)
python-tests-misc-integration:
@@ -335,13 +321,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Misc integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-misc
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure Functions + Durable Task integration tests
python-tests-functions:
@@ -413,13 +392,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Functions integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-functions
path: ./python/pytest.xml
if-no-files-found: ignore
python-tests-foundry:
name: Python Integration Tests - Foundry
@@ -437,10 +409,6 @@ jobs:
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
FOUNDRY_IMAGE_EMBEDDING_MODEL: ${{ vars.FOUNDRY_IMAGE_EMBEDDING_MODEL || '' }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
defaults:
run:
@@ -480,13 +448,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-foundry
path: ./python/pytest.xml
if-no-files-found: ignore
# TODO: Add python-tests-lab
@@ -536,7 +497,7 @@ jobs:
echo "Cosmos DB emulator did not become ready in time." >&2
exit 1
- name: Test with pytest (Cosmos integration)
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=pytest.xml
working-directory: ./python
- name: Surface failing tests
if: always()
@@ -547,76 +508,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Cosmos integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
if-no-files-found: ignore
# Flaky test trend report (aggregates per-job JUnit XML results)
python-flaky-test-report:
name: Flaky Test Report
if: >
always() &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs:
[
python-tests-openai,
python-tests-azure-openai,
python-tests-misc-integration,
python-tests-functions,
python-tests-foundry,
python-tests-cosmos,
]
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: test-results/
- name: Restore flaky report history cache
uses: actions/cache/restore@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-merge-${{ github.run_id }}
restore-keys: |
flaky-report-history-merge-
- name: Generate trend report
run: >
uv run python scripts/flaky_report/aggregate.py
../test-results/
flaky-report-history.json
flaky-test-report.md
- name: Post to Job Summary
if: always()
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save flaky report history cache
if: always()
uses: actions/cache/save@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-merge-${{ github.run_id }}
- name: Upload unified trend report
if: always()
uses: actions/upload-artifact@v7
with:
name: flaky-test-report
path: |
python/flaky-test-report.md
python/flaky-report-history.json
python-integration-tests-check:
if: always()
+9 -9
View File
@@ -22,9 +22,9 @@
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.22" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.1" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
@@ -42,15 +42,15 @@
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.4" />
<PackageVersion Include="System.ClientModel" Version="1.10.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.6" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.4" />
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.5" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.4" />
<PackageVersion Include="System.Text.Json" Version="10.0.6" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.6" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
@@ -104,8 +104,8 @@
<PackageVersion Include="Microsoft.Agents.Authentication.Msal" Version="1.3.171-beta" />
<PackageVersion Include="Microsoft.Agents.Hosting.AspNetCore" Version="1.3.171-beta" />
<!-- A2A -->
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
<PackageVersion Include="A2A" Version="0.3.4-preview" />
<PackageVersion Include="A2A.AspNetCore" Version="0.3.4-preview" />
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
<!-- Inference SDKs -->
@@ -188,4 +188,4 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
</Project>
+8 -10
View File
@@ -4,6 +4,9 @@
<BuildType Name="Publish" />
<BuildType Name="Release" />
</Configurations>
<Folder Name="/src/Aspire.Hosting.AgentFramework.DevUI/">
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
</Folder>
<Folder Name="/Samples/">
<File Path="samples/AGENTS.md" />
<File Path="samples/README.md" />
@@ -64,7 +67,6 @@
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
@@ -160,7 +162,6 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
@@ -343,13 +344,11 @@
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/A2A/">
<File Path="samples/02-agents/A2A/README.md" />
<Project Path="samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/A2A/">
<File Path="samples/04-hosting/A2A/README.md" />
<Project Path="samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
<Project Path="samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/">
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
@@ -532,7 +531,6 @@
<File Path="tests/Directory.Build.props" />
</Folder>
<Folder Name="/src/">
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
<Project Path="src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
<Project Path="src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
+4 -5
View File
@@ -1,14 +1,13 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.3.0</VersionPrefix>
<VersionPrefix>1.1.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260423</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260410.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260410.1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.3.0</GitTag>
<GitTag>1.1.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
</ItemGroup>
</Project>
@@ -1,36 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when
// creating an AIAgent from an A2A agent card using A2AClientOptions.PreferredBindings.
using A2A;
using Microsoft.Agents.AI;
var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set.");
// Initialize an A2ACardResolver to get an A2A agent card.
A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
// Get the agent card
AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
// Use A2AClientOptions to explicitly select the HTTP+JSON protocol binding.
// This tells the A2A client factory to prefer the HTTP+JSON interface when the agent card
// advertises multiple supported interfaces.
A2AClientOptions options = new()
{
PreferredBindings = [ProtocolBindingNames.HttpJson]
};
// To prefer JSON-RPC instead, use:
// A2AClientOptions options = new()
// {
// PreferredBindings = [ProtocolBindingNames.JsonRpc]
// };
// Create an instance of the AIAgent for an existing A2A agent, using the specified protocol binding.
AIAgent agent = agentCard.AsAIAgent(options: options);
// Invoke the agent and output the text result.
AgentResponse response = await agent.RunAsync("Tell me a joke about a pirate.");
Console.WriteLine(response);
@@ -1,27 +0,0 @@
# A2A Agent Protocol Selection
This sample demonstrates how to select the A2A protocol binding when creating an `AIAgent` from an A2A agent card.
A2A agents can expose multiple interfaces with different protocol bindings (e.g., HTTP+JSON, JSON-RPC). By default, `AsAIAgent()` prefers HTTP+JSON with JSON-RPC as a fallback. This sample shows how to use `A2AClientOptions.PreferredBindings` to explicitly control which protocol binding is used.
The sample:
- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable
- Configures `A2AClientOptions` to prefer the HTTP+JSON protocol binding
- Creates an `AIAgent` from the resolved agent card using the specified binding
- Sends a message to the agent and displays the response
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10.0 SDK or later
- An A2A agent server running and accessible via HTTP
**Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md
Set the following environment variable:
```powershell
$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host
```
@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A" />
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -1,55 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens,
// allowing recovery from stream interruptions without losing progress.
using A2A;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set.");
// Initialize an A2ACardResolver to get an A2A agent card.
A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
// Get the agent card
AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
// Create an instance of the AIAgent for an existing A2A agent specified by the agent card.
AIAgent agent = agentCard.AsAIAgent();
AgentSession session = await agent.CreateSessionAsync();
ResponseContinuationToken? continuationToken = null;
await foreach (var update in agent.RunStreamingAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session))
{
// Saving the continuation token to be able to reconnect to the same response stream later.
// Note: Continuation tokens are only returned for long-running tasks. If the underlying A2A agent
// returns a message instead of a task, the continuation token will not be initialized.
// A2A agents do not support stream resumption from a specific point in the stream,
// but only reconnection to obtain the same response stream from the beginning.
// So, A2A agents will return an initialized continuation token in the first update
// representing the beginning of the stream, and it will be null in all subsequent updates.
if (update.ContinuationToken is { } token)
{
continuationToken = token;
}
// Imitating stream interruption
break;
}
// Reconnect to the same response stream using the continuation token obtained from the previous run.
// As a first update, the agent will return an update representing the current state of the response at the moment of calling
// RunStreamingAsync with the same continuation token, followed by other updates until the end of the stream is reached.
if (continuationToken is not null)
{
await foreach (var update in agent.RunStreamingAsync(session, options: new() { ContinuationToken = continuationToken }))
{
if (!string.IsNullOrEmpty(update.Text))
{
Console.WriteLine(update.Text);
}
}
}
@@ -1,29 +0,0 @@
# A2A Agent Stream Reconnection
This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions without losing progress.
The sample:
- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable
- Sends a request to the agent and begins streaming the response
- Captures a continuation token from the stream for later reconnection
- Simulates a stream interruption by breaking out of the streaming loop
- Reconnects to the same response stream using the captured continuation token
- Displays the response received after reconnection
This pattern is useful when network interruptions or other failures may disrupt an ongoing streaming response, and you need to recover and continue processing.
> **Note:** Continuation tokens are only available when the underlying A2A agent returns a task. If the agent returns a message instead, the continuation token will not be initialized and stream reconnection is not applicable.
# Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10.0 SDK or later
- An A2A agent server running and accessible via HTTP
Set the following environment variable:
```powershell
$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host
```
@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -1,281 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to dynamically expand the set of function tools available to an
// agent during a function-calling loop. The agent starts with a single "RequestTools" function.
// When the model calls RequestTools with a description of the capabilities needed, the function
// uses the ambient FunctionInvocationContext to add new tools to ChatOptions.Tools. The agent
// can then use the newly added tools in subsequent iterations of the same function-calling loop.
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// Pre-defined tool implementations that can be loaded on demand.
[Description("Get the current weather for a city.")]
static string GetWeather([Description("The city name.")] string city) =>
city.ToUpperInvariant() switch
{
"SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
"NEW YORK" => "New York: 72°F, sunny and warm.",
"LONDON" => "London: 48°F, overcast with fog.",
_ => $"{city}: weather data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
};
[Description("Get the current local time for a city.")]
static string GetTime([Description("The city name.")] string city) =>
city.ToUpperInvariant() switch
{
"SEATTLE" => "Seattle: 9:00 AM PST",
"NEW YORK" => "New York: 12:00 PM EST",
"LONDON" => "London: 5:00 PM GMT",
_ => $"{city}: time data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
};
[Description("Convert a temperature from Fahrenheit to Celsius.")]
static string ConvertFahrenheitToCelsius([Description("The temperature in Fahrenheit.")] double fahrenheit) =>
$"{fahrenheit}°F = {(fahrenheit - 32) * 5 / 9:F1}°C";
// A registry of tool sets that can be loaded by description keyword.
Dictionary<string, List<AITool>> toolCatalog = new(StringComparer.OrdinalIgnoreCase)
{
["weather"] = [AIFunctionFactory.Create(GetWeather, name: "GetWeather")],
["time"] = [AIFunctionFactory.Create(GetTime, name: "GetTime")],
["temperature"] = [AIFunctionFactory.Create(ConvertFahrenheitToCelsius, name: "ConvertFahrenheitToCelsius")],
};
// The RequestTools function uses the ambient FunctionInvocationContext to add tools dynamically.
AIFunction requestToolsFunction = AIFunctionFactory.Create(
[Description("Request additional tools to be loaded based on a description of the functionality needed. " +
"Call this when you need capabilities that are not yet available in your current tool set.")] (
[Description("A description of the functionality required, e.g. 'weather', 'time', or 'temperature conversion'.")] string description
) =>
{
// Access the ambient FunctionInvocationContext provided by FunctionInvokingChatClient.
var context = FunctionInvokingChatClient.CurrentContext
?? throw new InvalidOperationException("No ambient FunctionInvocationContext available.");
var tools = context.Options?.Tools;
if (tools is null)
{
return "Unable to register new tools: ChatOptions.Tools is not available.";
}
// Find matching tool sets from the catalog.
List<string> addedToolNames = [];
foreach (var kvp in toolCatalog)
{
var keyword = kvp.Key;
var catalogTools = kvp.Value;
if (description.Contains(keyword, StringComparison.OrdinalIgnoreCase))
{
foreach (var tool in catalogTools)
{
// Avoid adding duplicates.
if (tool is AIFunction fn && !tools.Any(t => t is AIFunction existing && existing.Name == fn.Name))
{
tools.Add(tool);
addedToolNames.Add(fn.Name);
}
}
}
}
return addedToolNames.Count > 0
? "Successfully loaded tools"
: $"No tools matched the description '{description}'. Available categories: {string.Join(", ", toolCatalog.Keys)}.";
},
name: "RequestTools");
// Create the agent with only the RequestTools function initially.
// Insert chat client middleware that logs the tools available on each LLM call,
// making the dynamic expansion visible in the console output.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.Use(getResponseFunc: ToolLoggingMiddleware, getStreamingResponseFunc: ToolLoggingStreamingMiddleware)
.BuildAIAgent(
instructions: """
You are a helpful assistant. You start with limited tools.
When you need functionality that you don't currently have, call RequestTools with a description
of what you need. After new tools are loaded, use them to answer the user's question.
""",
tools: [requestToolsFunction]);
// Run a conversation that triggers dynamic tool expansion.
Console.WriteLine("=== Dynamic Function Tools Sample ===\n");
string[] prompts =
[
"What's the weather like in Seattle and London?",
"What time is it in New York?",
"Can you convert those temperatures to Celsius?"
];
// --- Non-Streaming Mode ---
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("=== Non-Streaming Mode ===");
Console.ResetColor();
Console.WriteLine();
AgentSession session = await agent.CreateSessionAsync();
foreach (var prompt in prompts)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("[User] ");
Console.ResetColor();
Console.WriteLine(prompt);
var response = await agent.RunAsync(prompt, session);
// Print all message contents including tool calls, tool results, and text.
foreach (var message in response.Messages)
{
foreach (var content in message.Contents)
{
switch (content)
{
case FunctionCallContent functionCall:
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
Console.ResetColor();
break;
case FunctionResultContent functionResult:
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
Console.ResetColor();
break;
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("[Agent] ");
Console.ResetColor();
Console.WriteLine(textContent.Text);
break;
}
}
}
Console.WriteLine();
}
// --- Streaming Mode ---
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("=== Streaming Mode ===");
Console.ResetColor();
Console.WriteLine();
AgentSession streamingSession = await agent.CreateSessionAsync();
foreach (var prompt in prompts)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("[User] ");
Console.ResetColor();
Console.WriteLine(prompt);
bool inAgentText = false;
await foreach (var update in agent.RunStreamingAsync(prompt, streamingSession))
{
foreach (var content in update.Contents)
{
switch (content)
{
case FunctionCallContent functionCall:
if (inAgentText)
{
Console.WriteLine();
inAgentText = false;
}
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
Console.ResetColor();
break;
case FunctionResultContent functionResult:
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
Console.ResetColor();
break;
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
if (!inAgentText)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("[Agent] ");
Console.ResetColor();
inAgentText = true;
}
Console.Write(textContent.Text);
break;
}
}
}
if (inAgentText)
{
Console.WriteLine();
}
Console.WriteLine();
}
// Chat client middleware that logs the number and names of tools on each LLM request.
async Task<ChatResponse> ToolLoggingMiddleware(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
IChatClient innerChatClient,
CancellationToken cancellationToken)
{
LogTools(options);
return await innerChatClient.GetResponseAsync(messages, options, cancellationToken);
}
// Streaming version of the tool logging middleware.
async IAsyncEnumerable<ChatResponseUpdate> ToolLoggingStreamingMiddleware(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
IChatClient innerChatClient,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
LogTools(options);
await foreach (var update in innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken))
{
yield return update;
}
}
// Shared helper to log the current tool set.
void LogTools(ChatOptions? options)
{
if (options?.Tools is { Count: > 0 } tools)
{
var toolNames = tools.OfType<AIFunction>().Select(t => t.Name);
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [Middleware] LLM call with {tools.Count} tool(s): {string.Join(", ", toolNames)}");
Console.ResetColor();
}
else
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine(" [Middleware] LLM call with 0 tools");
Console.ResetColor();
}
}
@@ -1,38 +0,0 @@
# Dynamic Function Tools
This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop.
## What it demonstrates
- The agent starts with only a single `RequestTools` function
- When the model needs capabilities it doesn't have, it calls `RequestTools` with a description of the functionality needed
- The `RequestTools` function uses the ambient `FunctionInvokingChatClient.CurrentContext` to access `ChatOptions.Tools` and add new tools at runtime
- The agent then uses the newly added tools in subsequent iterations of the same function-calling loop
## How it works
1. A tool catalog maps keywords (e.g. "weather", "time", "temperature") to pre-built `AIFunction` instances
2. The `RequestTools` function matches the description against catalog keywords and adds matching tools to `ChatOptions.Tools`
3. `FunctionInvokingChatClient` automatically picks up the new tools on the next iteration of its loop
## Prerequisites
- .NET 10 SDK or later
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
## Running the sample
Set the required environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
```
Run the sample:
```powershell
dotnet run
```
@@ -46,7 +46,6 @@ Before you begin, ensure you have the following prerequisites:
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.|
## Running the samples from the console
@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
</ItemGroup>
</Project>
@@ -1,148 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
// tools when creating an agent. The Foundry platform handles tool execution — the agent
// process does not invoke tools locally.
using System.ClientModel;
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Responses;
#pragma warning disable OPENAI001 // Experimental API
#pragma warning disable AAIP001 // AgentToolboxes is experimental
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
// Replace with your own Foundry toolbox name.
const string ToolboxName = "research_toolbox";
// Used only by CombineToolboxes — swap in a second toolbox you own.
const string SecondToolboxName = "analysis_toolbox";
// Replace with any question that exercises the tools configured in your toolbox.
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
await Main(projectClient, model, endpoint);
// await CombineToolboxes(projectClient, model, endpoint);
// ---------------------------------------------------------------------------
// Main: single toolbox
// ---------------------------------------------------------------------------
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
{
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
// Comment out if the toolbox already exists in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint);
// Omit the version to resolve the toolbox's current default version at runtime.
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
AIAgent agent = projectClient
.AsAIAgent(
model: model,
instructions: "You are a research assistant. Use the available tools to answer questions.",
tools: tools.ToList());
Console.WriteLine($"User: {Query}");
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
}
// ---------------------------------------------------------------------------
// Alternative: combine tools from multiple toolboxes
// ---------------------------------------------------------------------------
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
{
Console.WriteLine("=== Combine Toolboxes Example ===");
// Comment out if the toolboxes already exist in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint);
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
var allTools = toolboxA.Concat(toolboxB).ToList();
AIAgent agent = projectClient
.AsAIAgent(
model: model,
instructions: "You are a research assistant. Use all available tools to answer questions.",
tools: allTools);
Console.WriteLine($"User: {Query}");
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
}
// ---------------------------------------------------------------------------
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
// ---------------------------------------------------------------------------
static async Task CreateSampleToolboxAsync(string name, string endpoint)
{
// Toolboxes are normally configured in the Foundry portal or a deployment
// script, not the application itself. This helper exists so the sample can
// be run end-to-end without first setting a toolbox up by hand.
// The Foundry-Features header is currently required for toolbox CRUD operations.
var options = new AgentAdministrationClientOptions();
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
var adminClient = new AgentAdministrationClient(
new Uri(endpoint),
new DefaultAzureCredential(),
options);
var toolboxClient = adminClient.GetAgentToolboxes();
// Delete existing toolbox if present (ignore 404).
try
{
await toolboxClient.DeleteToolboxAsync(name);
Console.WriteLine($"Deleted existing toolbox '{name}'");
}
catch (ClientResultException ex) when (ex.Status == 404)
{
// Toolbox does not exist — nothing to delete.
}
// Create a fresh version with a single MCP tool.
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
serverLabel: "api-specs",
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
var created = (await toolboxClient.CreateToolboxVersionAsync(
name: name,
tools: [mcpTool],
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
}
// ---------------------------------------------------------------------------
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
// ---------------------------------------------------------------------------
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
{
private const string FeatureHeader = "Foundry-Features";
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Add(FeatureHeader, feature);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Add(FeatureHeader, feature);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
@@ -1,46 +0,0 @@
# Agent_Step25_ToolboxServerSideTools
This sample demonstrates loading a named Foundry toolbox and passing its tools as
**server-side tools** when creating an agent via `AsAIAgent()`.
When tools from a toolbox are passed this way, they are sent as tool definitions in
the Responses API request. The Foundry platform handles tool execution — the agent
process does not invoke tools locally.
This is the dotnet equivalent of the Python sample:
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
## Prerequisites
- A Microsoft Foundry project
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
The sample recreates the toolbox on each run, replacing any existing toolbox with
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
an existing toolbox unchanged.
## How it works
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
Foundry project API (resolving the default version if none is specified)
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
API request as server-side tool definitions
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
## Sample flows
| Flow | Description |
|------|-------------|
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
Uncomment the desired flow in the top-level statements to try each one.
## Running the sample
```bash
dotnet run
```
-1
View File
@@ -19,4 +19,3 @@ The getting started samples demonstrate the fundamental concepts and functionali
| [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files |
| [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients |
| [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development |
| [A2A Agents](./A2A/README.md) | Working with Agent-to-Agent (A2A) specific features |
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
@@ -13,6 +13,7 @@
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="System.Net.ServerSentEvents" />
</ItemGroup>
<ItemGroup>
@@ -18,12 +18,8 @@ AIAgent agent = agentCard.AsAIAgent();
AgentSession session = await agent.CreateSessionAsync();
// AllowBackgroundResponses must be true so the server returns immediately with a continuation token
// instead of blocking until the task is complete.
AgentRunOptions options = new() { AllowBackgroundResponses = true };
// Start the initial run with a long-running task.
AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session, options: options);
AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session);
// Poll until the response is complete.
while (response.ContinuationToken is { } token)
@@ -3,7 +3,7 @@
These samples demonstrate how to work with Agent-to-Agent (A2A) specific features in the Agent Framework.
For other samples that demonstrate how to use AIAgent instances,
see the [Getting Started With Agents](../Agents/README.md) samples.
see the [Getting Started With Agents](../../02-agents/Agents/README.md) samples.
## Prerequisites
@@ -15,8 +15,6 @@ See the README.md for each sample for the prerequisites for that sample.
|---|---|
|[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.|
|[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.|
|[A2A Agent Stream Reconnection](./A2AAgent_StreamReconnection/)|This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions.|
|[A2A Agent Protocol Selection](./A2AAgent_ProtocolSelection/)|This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when creating an AIAgent from an A2A agent card using A2AClientOptions.|
## Running the samples from the console
@@ -62,10 +62,12 @@ public static class Program
}
var agentResponse = await hostAgent.Agent!.RunAsync(message, session, cancellationToken: cancellationToken);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"\nAgent: {agentResponse.Text}");
Console.ResetColor();
foreach (var chatMessage in agentResponse.Messages)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"\nAgent: {chatMessage.Text}");
Console.ResetColor();
}
}
}
catch (Exception ex)
@@ -14,7 +14,7 @@ namespace A2AServer;
internal static class HostAgentFactory
{
internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, string[] agentUrls, IList<AITool>? tools = null)
internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, IList<AITool>? tools = null)
{
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
@@ -26,16 +26,16 @@ internal static class HostAgentFactory
AgentCard agentCard = agentType.ToUpperInvariant() switch
{
"INVOICE" => GetInvoiceAgentCard(agentUrls),
"POLICY" => GetPolicyAgentCard(agentUrls),
"LOGISTICS" => GetLogisticsAgentCard(agentUrls),
"INVOICE" => GetInvoiceAgentCard(),
"POLICY" => GetPolicyAgentCard(),
"LOGISTICS" => GetLogisticsAgentCard(),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
return new(agent, agentCard);
}
internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, string[] agentUrls, IList<AITool>? tools = null)
internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList<AITool>? tools = null)
{
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient(model)
@@ -43,9 +43,9 @@ internal static class HostAgentFactory
AgentCard agentCard = agentType.ToUpperInvariant() switch
{
"INVOICE" => GetInvoiceAgentCard(agentUrls),
"POLICY" => GetPolicyAgentCard(agentUrls),
"LOGISTICS" => GetLogisticsAgentCard(agentUrls),
"INVOICE" => GetInvoiceAgentCard(),
"POLICY" => GetPolicyAgentCard(),
"LOGISTICS" => GetLogisticsAgentCard(),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
@@ -53,7 +53,7 @@ internal static class HostAgentFactory
}
#region private
private static AgentCard GetInvoiceAgentCard(string[] agentUrls)
private static AgentCard GetInvoiceAgentCard()
{
var capabilities = new AgentCapabilities()
{
@@ -82,11 +82,10 @@ internal static class HostAgentFactory
DefaultOutputModes = ["text"],
Capabilities = capabilities,
Skills = [invoiceQuery],
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
};
}
private static AgentCard GetPolicyAgentCard(string[] agentUrls)
private static AgentCard GetPolicyAgentCard()
{
var capabilities = new AgentCapabilities()
{
@@ -115,11 +114,10 @@ internal static class HostAgentFactory
DefaultOutputModes = ["text"],
Capabilities = capabilities,
Skills = [policyQuery],
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
};
}
private static AgentCard GetLogisticsAgentCard(string[] agentUrls)
private static AgentCard GetLogisticsAgentCard()
{
var capabilities = new AgentCapabilities()
{
@@ -148,29 +146,7 @@ internal static class HostAgentFactory
DefaultOutputModes = ["text"],
Capabilities = capabilities,
Skills = [logisticsQuery],
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
};
}
private static List<AgentInterface> CreateAgentInterfaces(string[] agentUrls)
{
List<AgentInterface> agentInterfaces = [];
agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
{
Url = url,
ProtocolBinding = ProtocolBindingNames.JsonRpc,
ProtocolVersion = "1.0",
}));
agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
{
Url = url,
ProtocolBinding = ProtocolBindingNames.HttpJson,
ProtocolVersion = "1.0",
}));
return agentInterfaces;
}
#endregion
}
@@ -25,6 +25,10 @@ for (var i = 0; i < args.Length; i++)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
var app = builder.Build();
var httpClient = app.Services.GetRequiredService<IHttpClientFactory>().CreateClient();
var logger = app.Logger;
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
@@ -34,15 +38,14 @@ IConfigurationRoot configuration = new ConfigurationBuilder()
string? apiKey = configuration["OPENAI_API_KEY"];
string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini";
string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"];
string[] agentUrls = (builder.Configuration["urls"] ?? "http://localhost:5000").Split(';');
var invoiceQueryPlugin = new InvoiceQuery();
IList<AITool> tools =
[
[
AIFunctionFactory.Create(invoiceQueryPlugin.QueryInvoices),
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByTransactionId),
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByInvoiceId)
];
];
AIAgent hostA2AAgent;
AgentCard hostA2AAgentCard;
@@ -51,9 +54,9 @@ if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentName))
{
(hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch
{
"INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls, tools),
"POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls),
"LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls),
"INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, tools),
"POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName),
"LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
}
@@ -65,7 +68,7 @@ else if (!string.IsNullOrEmpty(apiKey))
agentType, model, apiKey, "InvoiceAgent",
"""
You specialize in handling queries related to invoices.
""", agentUrls, tools),
""", tools),
"POLICY" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
agentType, model, apiKey, "PolicyAgent",
"""
@@ -81,7 +84,7 @@ else if (!string.IsNullOrEmpty(apiKey))
resolution in SAP CRM and notify the customer via email within 2 business days, referencing the
original invoice and the credit memo number. Use the 'Formal Credit Notification' email
template."
""", agentUrls),
"""),
"LOGISTICS" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
agentType, model, apiKey, "LogisticsAgent",
"""
@@ -92,7 +95,7 @@ else if (!string.IsNullOrEmpty(apiKey))
Shipment number: SHPMT-SAP-001
Item: TSHIRT-RED-L
Quantity: 900
""", agentUrls),
"""),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
}
@@ -101,12 +104,10 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
builder.AddA2AServer(hostA2AAgent);
var app = builder.Build();
app.MapA2AHttpJson(hostA2AAgent, "/");
app.MapA2AJsonRpc(hostA2AAgent, "/");
app.MapWellKnownAgentCard(hostA2AAgentCard);
var a2aTaskManager = app.MapA2A(
hostA2AAgent,
path: "/",
agentCard: hostA2AAgentCard,
taskManager => app.MapWellKnownAgentCard(taskManager, "/"));
await app.RunAsync();
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using A2A.AspNetCore;
using AgentWebChat.AgentHost;
using AgentWebChat.AgentHost.Custom;
using AgentWebChat.AgentHost.Utilities;
@@ -145,9 +146,6 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
});
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
var app = builder.Build();
app.MapOpenApi();
@@ -156,9 +154,17 @@ app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Agents
// Configure the HTTP request pipeline.
app.UseExceptionHandler();
// Expose A2A servers over HTTP with JSON payloads
app.MapA2AHttpJson(pirateAgentBuilder, path: "/a2a/pirate");
app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves");
// attach a2a with simple message communication
app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate");
app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard: new()
{
Name = "Knights and Knaves",
Description = "An agent that helps you solve the knights and knaves puzzle.",
Version = "1.0",
// Url can be not set, and SDK will help assign it.
// Url = "http://localhost:5390/a2a/knights-and-knaves"
});
app.MapDevUI();
@@ -43,21 +43,20 @@ internal sealed class A2AAgentClient : AgentClientBase
{
// Convert all messages to A2A parts and create a single message
var parts = messages.ToParts();
var a2aMessage = new Message
var a2aMessage = new AgentMessage
{
MessageId = Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = Role.User,
Role = MessageRole.User,
Parts = parts
};
var messageSendParams = new SendMessageRequest { Message = a2aMessage };
var messageSendParams = new MessageSendParams { Message = a2aMessage };
var a2aResponse = await a2aClient.SendMessageAsync(messageSendParams, cancellationToken);
// Handle different response types
if (a2aResponse.PayloadCase == SendMessageResponseCase.Message)
if (a2aResponse is AgentMessage message)
{
var message = a2aResponse.Message!;
var responseMessage = message.ToChatMessage();
if (responseMessage is { Contents.Count: > 0 })
{
@@ -68,10 +67,9 @@ internal sealed class A2AAgentClient : AgentClientBase
});
}
}
else if (a2aResponse.PayloadCase == SendMessageResponseCase.Task)
else if (a2aResponse is AgentTask agentTask)
{
// Manually convert AgentTask artifacts to ChatMessages since the extension method is internal
var agentTask = a2aResponse.Task!;
if (agentTask.Artifacts is not null)
{
foreach (var artifact in agentTask.Artifacts)
+1 -1
View File
@@ -16,7 +16,7 @@ were local agents. These are supported using various `AIAgent` subclasses.
| [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting |
| [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations |
| [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative |
| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks |
| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A |
| [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos |
## Getting Started
@@ -2,22 +2,14 @@
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<IsPackable>true</IsPackable>
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
<!-- Suppress analyzer warnings for Aspire integration code -->
<!-- IL2026/IL3050: Suppress trimming/AOT warnings - DevUI is a dev-only tool not intended for AOT -->
<NoWarn>$(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework DevUI for Aspire</Title>
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Aspire.Hosting.AgentFramework.DevUI.UnitTests" />
</ItemGroup>
@@ -30,7 +22,4 @@
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
</Project>
+99 -182
View File
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -27,7 +28,7 @@ public sealed class A2AAgent : AIAgent
{
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
private readonly IA2AClient _a2aClient;
private readonly A2AClient _a2aClient;
private readonly string? _id;
private readonly string? _name;
private readonly string? _description;
@@ -41,7 +42,7 @@ public sealed class A2AAgent : AIAgent
/// <param name="name">The the name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(a2aClient);
@@ -99,47 +100,64 @@ public sealed class A2AAgent : AIAgent
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
A2AResponse? a2aResponse = null;
if (GetContinuationToken(messages, options) is { } token)
{
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
return this.ConvertToAgentResponse(agentTask);
a2aResponse = await this._a2aClient.GetTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
}
SendMessageRequest sendParams = new()
else
{
Message = CreateA2AMessage(typedSession, messages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata(),
Configuration = new SendMessageConfiguration { ReturnImmediately = options?.AllowBackgroundResponses is true }
};
MessageSendParams sendParams = new()
{
Message = CreateA2AMessage(typedSession, messages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
};
SendMessageResponse a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
}
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
if (a2aResponse.PayloadCase == SendMessageResponseCase.Message)
if (a2aResponse is AgentMessage message)
{
var message = a2aResponse.Message!;
UpdateSession(typedSession, message.ContextId);
return this.ConvertToAgentResponse(message);
return new AgentResponse
{
AgentId = this.Id,
ResponseId = message.MessageId,
FinishReason = ChatFinishReason.Stop,
RawRepresentation = message,
Messages = [message.ToChatMessage()],
AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
};
}
if (a2aResponse.PayloadCase == SendMessageResponseCase.Task)
if (a2aResponse is AgentTask agentTask)
{
var agentTask = a2aResponse.Task!;
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
return this.ConvertToAgentResponse(agentTask);
var response = new AgentResponse
{
AgentId = this.Id,
ResponseId = agentTask.Id,
FinishReason = MapTaskStateToFinishReason(agentTask.Status.State),
RawRepresentation = agentTask,
Messages = agentTask.ToChatMessages() ?? [],
ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State),
AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(),
};
if (agentTask.ToChatMessages() is { Count: > 0 } taskMessages)
{
response.Messages = taskMessages;
}
return response;
}
throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.PayloadCase}");
throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}");
}
/// <inheritdoc/>
@@ -151,61 +169,59 @@ public sealed class A2AAgent : AIAgent
this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name);
ConfiguredCancelableAsyncEnumerable<StreamResponse> streamEvents;
ConfiguredCancelableAsyncEnumerable<SseItem<A2AEvent>> a2aSseEvents;
if (GetContinuationToken(messages, options) is { } token)
if (options?.ContinuationToken is not null)
{
streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
// Task stream resumption is not well defined in the A2A v2.* specification, leaving it to the agent implementations.
// The v3.0 specification improves this by defining task stream reconnection that allows obtaining the same stream
// from the beginning, but it does not define stream resumption from a specific point in the stream.
// Therefore, the code should be updated once the A2A .NET library supports the A2A v3.0 specification,
// and AF has the necessary model to allow consumers to know whether they need to resume the stream and add new updates to
// the existing ones or reconnect the stream and obtain all updates again.
// For more details, see the following issue: https://github.com/microsoft/agent-framework/issues/1764
throw new InvalidOperationException("Reconnecting to task streams using continuation tokens is not supported yet.");
// a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
}
else
{
SendMessageRequest sendParams = new()
{
Message = CreateA2AMessage(typedSession, messages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
};
streamEvents = this._a2aClient.SendStreamingMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
}
MessageSendParams sendParams = new()
{
Message = CreateA2AMessage(typedSession, messages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
};
a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
string? contextId = null;
string? taskId = null;
await foreach (var streamResponse in streamEvents)
await foreach (var sseEvent in a2aSseEvents)
{
switch (streamResponse.PayloadCase)
if (sseEvent.Data is AgentMessage message)
{
case StreamResponseCase.Message:
var message = streamResponse.Message!;
contextId = message.ContextId;
yield return this.ConvertToAgentResponseUpdate(message);
break;
contextId = message.ContextId;
case StreamResponseCase.Task:
var task = streamResponse.Task!;
contextId = task.ContextId;
taskId = task.Id;
yield return this.ConvertToAgentResponseUpdate(task);
break;
yield return this.ConvertToAgentResponseUpdate(message);
}
else if (sseEvent.Data is AgentTask task)
{
contextId = task.ContextId;
taskId = task.Id;
case StreamResponseCase.StatusUpdate:
var statusUpdate = streamResponse.StatusUpdate!;
contextId = statusUpdate.ContextId;
taskId = statusUpdate.TaskId;
yield return this.ConvertToAgentResponseUpdate(statusUpdate);
break;
yield return this.ConvertToAgentResponseUpdate(task);
}
else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent)
{
contextId = taskUpdateEvent.ContextId;
taskId = taskUpdateEvent.TaskId;
case StreamResponseCase.ArtifactUpdate:
var artifactUpdate = streamResponse.ArtifactUpdate!;
contextId = artifactUpdate.ContextId;
taskId = artifactUpdate.TaskId;
yield return this.ConvertToAgentResponseUpdate(artifactUpdate);
break;
default:
throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {streamResponse.PayloadCase}");
yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent);
}
else
{
throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {sseEvent.Data.GetType().FullName ?? "null"}");
}
}
@@ -224,7 +240,7 @@ public sealed class A2AAgent : AIAgent
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
=> base.GetService(serviceType, serviceKey)
?? (serviceType == typeof(IA2AClient) ? this._a2aClient
?? (serviceType == typeof(A2AClient) ? this._a2aClient
: serviceType == typeof(AIAgentMetadata) ? s_agentMetadata
: null);
@@ -248,75 +264,6 @@ public sealed class A2AAgent : AIAgent
return typedSession;
}
/// <summary>
/// Subscribes to task updates, falling back to <see cref="A2AClient.GetTaskAsync"/>
/// when the task has already reached a terminal state and the server responds with
/// <see cref="A2AErrorCode.UnsupportedOperation"/>.
/// </summary>
/// <remarks>
/// Per A2A spec §3.1.6, subscribing to a task in a terminal state (completed, failed,
/// canceled, or rejected) results in an <c>UnsupportedOperationError</c>.
/// See: <see href="https://a2a-protocol.org/latest/specification/#332-error-handling"/>.
/// </remarks>
private async IAsyncEnumerable<StreamResponse> SubscribeToTaskWithFallbackAsync(
string taskId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var subscribeStream = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = taskId }, cancellationToken);
var enumerator = subscribeStream.GetAsyncEnumerator(cancellationToken);
// yield return cannot appear inside a try block that has catch clauses,
// so we manually advance the enumerator within try/catch and yield outside it.
// The outer try/finally (no catch) is allowed to contain yield return in C#.
StreamResponse? fallbackResponse = null;
bool disposed = false;
try
{
while (true)
{
bool hasNext;
try
{
hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.UnsupportedOperation)
{
this._logger.LogA2ASubscribeToTaskFallback(this.Id, this.Name, taskId, ex.Message);
// Dispose the enumerator before the fallback call to release the HTTP/SSE connection.
await enumerator.DisposeAsync().ConfigureAwait(false);
disposed = true;
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken).ConfigureAwait(false);
fallbackResponse = new StreamResponse { Task = agentTask };
break;
}
if (!hasNext)
{
break;
}
yield return enumerator.Current;
}
if (fallbackResponse is not null)
{
yield return fallbackResponse;
}
}
finally
{
if (!disposed)
{
await enumerator.DisposeAsync().ConfigureAwait(false);
}
}
}
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null)
{
if (session is null)
@@ -337,7 +284,7 @@ public sealed class A2AAgent : AIAgent
session.TaskId = taskId;
}
private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable<ChatMessage> messages)
private static AgentMessage CreateA2AMessage(A2AAgentSession typedSession, IEnumerable<ChatMessage> messages)
{
var a2aMessage = messages.ToA2AMessage();
@@ -377,34 +324,7 @@ public sealed class A2AAgent : AIAgent
return null;
}
private AgentResponse ConvertToAgentResponse(Message message)
{
return new AgentResponse
{
AgentId = this.Id,
ResponseId = message.MessageId,
FinishReason = ChatFinishReason.Stop,
RawRepresentation = message,
Messages = [message.ToChatMessage()],
AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
};
}
private AgentResponse ConvertToAgentResponse(AgentTask task)
{
return new AgentResponse
{
AgentId = this.Id,
ResponseId = task.Id,
FinishReason = MapTaskStateToFinishReason(task.Status.State),
RawRepresentation = task,
Messages = task.ToChatMessages() ?? [],
ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
};
}
private AgentResponseUpdate ConvertToAgentResponseUpdate(Message message)
private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentMessage message)
{
return new AgentResponseUpdate
{
@@ -429,35 +349,32 @@ public sealed class A2AAgent : AIAgent
RawRepresentation = task,
Role = ChatRole.Assistant,
Contents = task.ToAIContents(),
ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
};
}
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskStatusUpdateEvent statusUpdateEvent)
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent)
{
return new AgentResponseUpdate
AgentResponseUpdate responseUpdate = new()
{
AgentId = this.Id,
ResponseId = statusUpdateEvent.TaskId,
RawRepresentation = statusUpdateEvent,
ResponseId = taskUpdateEvent.TaskId,
RawRepresentation = taskUpdateEvent,
Role = ChatRole.Assistant,
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
};
}
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskArtifactUpdateEvent artifactUpdateEvent)
{
return new AgentResponseUpdate
if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent)
{
AgentId = this.Id,
ResponseId = artifactUpdateEvent.TaskId,
RawRepresentation = artifactUpdateEvent,
Role = ChatRole.Assistant,
Contents = artifactUpdateEvent.Artifact.ToAIContents(),
AdditionalProperties = artifactUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
};
responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents();
responseUpdate.RawRepresentation = artifactUpdateEvent;
}
else if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent)
{
responseUpdate.FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State);
}
return responseUpdate;
}
private static ChatFinishReason? MapTaskStateToFinishReason(TaskState state)
@@ -34,17 +34,4 @@ internal static partial class A2AAgentLogMessages
string methodName,
string agentId,
string? agentName);
/// <summary>
/// Logs <see cref="A2AAgent"/> falling back to GetTaskAsync after SubscribeToTaskAsync failed with UnsupportedOperation.
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "A2AAgent {AgentId}/{AgentName} SubscribeToTask for task '{TaskId}' failed with UnsupportedOperation: {ErrorMessage}. Falling back to GetTaskAsync.")]
public static partial void LogA2ASubscribeToTaskFallback(
this ILogger logger,
string agentId,
string? agentName,
string taskId,
string errorMessage);
}
@@ -52,7 +52,7 @@ internal class A2AContinuationToken : ResponseContinuationToken
{
case "taskId":
reader.Read();
taskId = reader.GetString() ?? throw new JsonException("The 'taskId' property must contain a non-null string value.");
taskId = reader.GetString()!;
break;
default:
throw new JsonException($"Unrecognized property '{propertyName}'.");
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net.Http;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
@@ -24,15 +25,12 @@ public static class A2AAgentCardExtensions
/// </remarks>
/// <param name="card">The <see cref="AgentCard" /> to use for the agent creation.</param>
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
/// <param name="options">
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
/// </param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null)
public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
{
var a2aClient = A2AClientFactory.Create(card, httpClient, options);
// Create the A2A client using the agent URL from the card.
var a2aClient = new A2AClient(new Uri(card.Url), httpClient);
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
}
@@ -34,18 +34,14 @@ public static class A2ACardResolverExtensions
/// </remarks>
/// <param name="resolver">The <see cref="A2ACardResolver" /> to use for the agent creation.</param>
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
/// <param name="options">
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
/// </param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
{
// Obtain the agent card from the resolver.
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
return agentCard.AsAIAgent(httpClient, options, loggerFactory);
return agentCard.AsAIAgent(httpClient, loggerFactory);
}
}
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging;
namespace A2A;
/// <summary>
/// Provides extension methods for <see cref="IA2AClient"/>
/// Provides extension methods for <see cref="A2AClient"/>
/// to simplify the creation of A2A agents.
/// </summary>
/// <remarks>
@@ -29,12 +29,12 @@ public static class A2AClientExtensions
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#3-direct-configuration--private-discovery">Direct Configuration / Private Discovery</see>
/// discovery mechanism.
/// </remarks>
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
/// <param name="client">The <see cref="A2AClient" /> to use for the agent.</param>
/// <param name="id">The unique identifier for the agent.</param>
/// <param name="name">The the name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
public static AIAgent AsAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
new A2AAgent(client, id, name, description, loggerFactory);
}
@@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI;
/// </summary>
internal static class ChatMessageExtensions
{
internal static Message ToA2AMessage(this IEnumerable<ChatMessage> messages)
internal static AgentMessage ToA2AMessage(this IEnumerable<ChatMessage> messages)
{
List<Part> allParts = [];
@@ -23,10 +23,10 @@ internal static class ChatMessageExtensions
}
}
return new Message
return new AgentMessage
{
MessageId = Guid.NewGuid().ToString("N"),
Role = Role.User,
Role = MessageRole.User,
Parts = allParts,
};
}
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>
@@ -1,57 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
namespace Azure.AI.Projects;
/// <summary>
/// Provides extension methods on <see cref="AIProjectClient"/> for fetching
/// Foundry toolbox definitions as server-side tools.
/// </summary>
/// <remarks>
/// These extensions mirror Python's <c>FoundryChatClient.get_toolbox()</c> pattern,
/// allowing a single call on the project client to retrieve tools ready for use
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class AIProjectClientToolboxExtensions
{
/// <summary>
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
/// ready for use as server-side tools in the Responses API.
/// </summary>
/// <param name="projectClient">The <see cref="AIProjectClient"/> to use. Cannot be <see langword="null"/>.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
/// <exception cref="System.ArgumentNullException">
/// Thrown when <paramref name="projectClient"/> or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
public static async Task<IReadOnlyList<AITool>> GetToolboxToolsAsync(
this AIProjectClient projectClient,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(projectClient);
Throw.IfNullOrWhitespace(name);
var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes();
var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
return toolboxVersion.ToAITools();
}
}
@@ -1,223 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
#pragma warning disable IL2026 // ModelReaderWriter.Read<ResponseTool> uses reflection; suppressed for Azure SDK model types.
#pragma warning disable IL3050 // ModelReaderWriter.Read<ResponseTool> requires dynamic code; suppressed for Azure SDK model types.
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides methods for fetching Foundry toolbox definitions and converting their tools
/// to <see cref="AITool"/> instances for use as server-side tools in the Responses API.
/// </summary>
/// <remarks>
/// <para>
/// When tools from a toolbox are passed to a Foundry agent (e.g. via <c>AsAIAgent(model, instructions, tools: ...)</c>),
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
/// handles tool execution — the agent process does not invoke tools locally.
/// </para>
/// <para>
/// This is the dotnet equivalent of Python's <c>FoundryChatClient.get_toolbox()</c> pattern.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryToolbox
{
/// <summary>
/// Fetches a toolbox version from the Foundry project and returns the raw SDK <see cref="ToolboxVersion"/>.
/// </summary>
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically (requires an additional API call).
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>The <see cref="ToolboxVersion"/> containing tool definitions.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
public static async Task<ToolboxVersion> GetToolboxVersionAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(projectEndpoint);
Throw.IfNull(credential);
Throw.IfNullOrWhitespace(name);
var toolboxClient = CreateToolboxClient(projectEndpoint, credential);
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
/// ready for use as server-side tools in the Responses API.
/// </summary>
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
public static async Task<IReadOnlyList<AITool>> GetToolsAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false);
return toolboxVersion.ToAITools();
}
/// <summary>
/// Converts the tools in a <see cref="ToolboxVersion"/> to <see cref="AITool"/> instances
/// suitable for use as server-side tools in the Responses API.
/// </summary>
/// <param name="toolboxVersion">The toolbox version whose tools to convert.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="toolboxVersion"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// Each <see cref="ProjectsAgentTool"/> in the toolbox is cast to <see cref="ResponseTool"/>
/// and converted via <c>AsAITool()</c>. Non-function hosted tools (MCP, web_search,
/// code_interpreter, etc.) are included as server-side tool definitions — the Foundry
/// platform handles their execution.
/// </para>
/// <para>
/// Non-function tools are sanitized to remove decoration fields (<c>name</c>, <c>description</c>)
/// that the toolbox API returns but the Responses API rejects.
/// </para>
/// </remarks>
public static IReadOnlyList<AITool> ToAITools(this ToolboxVersion toolboxVersion)
{
Throw.IfNull(toolboxVersion);
if (toolboxVersion.Tools?.Any() != true)
{
return [];
}
return toolboxVersion.Tools
.Select(SanitizeAndConvert)
.ToList();
}
#region Internal helpers (visible to unit tests via InternalsVisibleTo)
/// <summary>
/// Sanitizes a <see cref="ProjectsAgentTool"/> by removing decoration fields that the
/// toolbox API returns but the Responses API rejects, then converts to <see cref="AITool"/>.
/// </summary>
/// <remarks>
/// The Azure AI Projects toolbox API may return <c>name</c> and <c>description</c> on
/// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API
/// rejects at least <c>name</c> with "Unknown parameter: 'tools[0].name'". We strip
/// these decoration fields for non-function tools. Function tools keep them since
/// <c>name</c> and <c>description</c> are expected parts of the function schema.
/// </remarks>
internal static AITool SanitizeAndConvert(ProjectsAgentTool tool)
{
var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J"));
var node = JsonNode.Parse(toolJson.ToString());
if (node is not JsonObject obj)
{
return ((ResponseTool)tool).AsAITool();
}
var toolType = obj["type"]?.GetValue<string>();
// Function tools need name/description — don't strip
if (toolType is "function" or "custom")
{
return ((ResponseTool)tool).AsAITool();
}
// Strip decoration fields that the Responses API rejects
bool modified = false;
modified |= obj.Remove("name");
modified |= obj.Remove("description");
if (!modified)
{
return ((ResponseTool)tool).AsAITool();
}
var sanitizedJson = obj.ToJsonString();
var sanitizedTool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(sanitizedJson))!;
return sanitizedTool.AsAITool();
}
internal static async Task<ToolboxVersion> GetToolboxVersionAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version,
AgentAdministrationClientOptions? clientOptions,
CancellationToken cancellationToken)
{
Throw.IfNull(projectEndpoint);
Throw.IfNull(credential);
Throw.IfNullOrWhitespace(name);
var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions);
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
}
internal static AgentToolboxes CreateToolboxClient(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
AgentAdministrationClientOptions? clientOptions = null)
{
clientOptions ??= new AgentAdministrationClientOptions();
var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions);
return adminClient.GetAgentToolboxes();
}
internal static async Task<ToolboxVersion> GetToolboxVersionCoreAsync(
AgentToolboxes toolboxClient,
string name,
string? version,
CancellationToken cancellationToken)
{
if (version is null)
{
var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false);
version = record.Value.DefaultVersion
?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version.");
}
var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false);
return result.Value;
}
#endregion
}
@@ -237,7 +237,7 @@ internal static class InputConverter
{
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
OutputItemReasoningItem => null,
_ => null
};
@@ -332,7 +332,7 @@ internal static class InputConverter
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
}
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
{
return new ChatMessage(
ChatRole.Tool,
@@ -251,25 +251,16 @@ internal static class OutputConverter
var outputTokens = details.OutputTokenCount ?? 0;
var totalTokens = details.TotalTokenCount ?? 0;
var cachedTokens = details.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cached) ?? false
? cached : 0;
var reasoningTokens = details.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoning) ?? false
? reasoning : 0;
if (existing is not null)
{
inputTokens += existing.InputTokens;
outputTokens += existing.OutputTokens;
totalTokens += existing.TotalTokens;
cachedTokens += existing.InputTokensDetails?.CachedTokens ?? 0;
reasoningTokens += existing.OutputTokensDetails?.ReasoningTokens ?? 0;
}
return new ResponseUsage(
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
inputTokens: inputTokens,
inputTokensDetails: new ResponseUsageInputTokensDetails(cachedTokens),
outputTokens: outputTokens,
outputTokensDetails: new ResponseUsageOutputTokensDetails(reasoningTokens),
totalTokens: totalTokens);
}
@@ -1,138 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using A2A;
using A2A.AspNetCore;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.AspNetCore.Builder;
/// <summary>
/// Provides extension methods for mapping A2A protocol endpoints for AI agents.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class A2AEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps A2A HTTP+JSON endpoints for the specified agent to the given path.
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for the agent.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2AHttpJson(agentBuilder.Name, path);
}
/// <summary>
/// Maps A2A HTTP+JSON endpoints for the specified agent to the given path.
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent whose name identifies the registered A2A server.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
return endpoints.MapA2AHttpJson(agent.Name, path);
}
/// <summary>
/// Maps A2A HTTP+JSON endpoints for the agent with the specified name to the given path.
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, string agentName, string path)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
var a2aServer = endpoints.ServiceProvider.GetKeyedService<A2AServer>(agentName)
?? throw new InvalidOperationException(
$"No A2AServer is registered for agent '{agentName}'. " +
$"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one.");
// TODO: The stub AgentCard is temporary and will be removed once the A2A SDK either removes the
// agentCard parameter of MapHttpA2A or makes it optional. MapHttpA2A exposes the agent card via a
// GET {path}/card endpoint that is not part of the A2A spec, so it is not expected to be consumed
// by any agent - returning a stub agent card here is safe.
var stubAgentCard = new AgentCard { Name = "A2A Agent" };
return endpoints.MapHttpA2A(a2aServer, stubAgentCard, path);
}
/// <summary>
/// Maps A2A JSON-RPC endpoints for the specified agent to the given path.
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for the agent.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2AJsonRpc(agentBuilder.Name, path);
}
/// <summary>
/// Maps A2A JSON-RPC endpoints for the specified agent to the given path.
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent whose name identifies the registered A2A server.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
return endpoints.MapA2AJsonRpc(agent.Name, path);
}
/// <summary>
/// Maps A2A JSON-RPC endpoints for the agent with the specified name to the given path.
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, string agentName, string path)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
var a2aServer = endpoints.ServiceProvider.GetKeyedService<A2AServer>(agentName)
?? throw new InvalidOperationException(
$"No A2AServer is registered for agent '{agentName}'. " +
$"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one.");
return endpoints.MapA2A(a2aServer, path);
}
}
@@ -0,0 +1,385 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using A2A;
using A2A.AspNetCore;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.A2A;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.AspNetCore.Builder;
/// <summary>
/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
{
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
=> endpoints.MapA2A(agentBuilder, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
=> endpoints.MapA2A(agentName, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action<ITaskManager> configureTaskManager)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, configureTaskManager);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action<ITaskManager> configureTaskManager)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, configureTaskManager);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard)
=> endpoints.MapA2A(agentBuilder, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard)
=> endpoints.MapA2A(agentName, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, configureTaskManager);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
=> endpoints.MapA2A(agent, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode)
=> endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
return endpointConventionBuilder;
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard)
=> endpoints.MapA2A(agent, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode)
=> endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
return endpointConventionBuilder;
}
/// <summary>
/// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager.
/// TaskManager should be preconfigured before calling this method.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="taskManager">Pre-configured A2A TaskManager to use for A2A endpoints handling.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, string path)
{
// note: current SDK version registers multiple `.well-known/agent.json` handlers here.
// it makes app return HTTP 500, but will be fixed once new A2A SDK is released.
// see https://github.com/microsoft/agent-framework/issues/476 for details
A2ARouteBuilderExtensions.MapA2A(endpoints, taskManager, path);
return endpoints.MapHttpA2A(taskManager, path);
}
}
@@ -1,12 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI.Hosting.A2A.AspNetCore</RootNamespace>
<VersionSuffix>preview</VersionSuffix>
<!-- RT0002: Microsoft.Agents.AI.Hosting.A2A is intentionally referenced as a transitive dependency
so that consumers of this package automatically get the AddA2AServer registration extensions. -->
<NoWarn>$(NoWarn);RT0002</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -16,7 +13,7 @@
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A.AspNetCore" />
</ItemGroup>
@@ -24,7 +21,7 @@
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
<PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
</ItemGroup>
@@ -1,251 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.Converters;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// An <see cref="IAgentHandler"/> implementation that bridges an <see cref="AIAgent"/> to the
/// A2A (Agent2Agent) protocol. Handles message execution and cancellation by delegating to
/// the underlying agent and translating responses into A2A events.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
internal sealed class A2AAgentHandler : IAgentHandler
{
private readonly AIHostAgent _hostAgent;
private readonly AgentRunMode _runMode;
/// <summary>
/// Initializes a new instance of the <see cref="A2AAgentHandler"/> class.
/// </summary>
/// <param name="hostAgent">The hosted agent that provides the execution logic.</param>
/// <param name="runMode">Controls whether the agent runs in background mode.</param>
public A2AAgentHandler(
AIHostAgent hostAgent,
AgentRunMode runMode)
{
ArgumentNullException.ThrowIfNull(hostAgent);
ArgumentNullException.ThrowIfNull(runMode);
this._hostAgent = hostAgent;
this._runMode = runMode;
}
/// <inheritdoc/>
public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
{
// Handle task updates
if (context.IsContinuation)
{
return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken);
}
// Handle messages received via streaming endpoint
if (context.StreamingResponse)
{
return this.HandleNewMessageStreamingAsync(context, eventQueue, cancellationToken);
}
// Handle new messages received via non-streaming endpoint
return this.HandleNewMessageAsync(context, eventQueue, cancellationToken);
}
/// <inheritdoc/>
public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
{
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
await taskUpdater.CancelAsync(cancellationToken).ConfigureAwait(false);
}
private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
{
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
// AIAgent does not support resuming from arbitrary prior tasks.
// Throw explicitly so the client gets a clear error rather than a response
// that silently ignores the referenced task context.
if (context.Message?.ReferenceTaskIds is { Count: > 0 })
{
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context.");
}
List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
// Decide whether to run in background based on user preferences and agent capabilities
var decisionContext = new A2ARunDecisionContext(context);
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
var options = context.Metadata is not { Count: > 0 }
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
AgentResponse response;
try
{
response = await this._hostAgent.RunAsync(
chatMessages,
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
finally
{
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
}
if (response.ContinuationToken is null)
{
// Return a lightweight message response (no task lifecycle needed).
var message = CreateMessageFromResponse(contextId, response);
await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
else
{
// Long-running operation: emit task lifecycle events.
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
await taskUpdater.SubmitAsync(cancellationToken).ConfigureAwait(false);
Message? progressMessage = response.Messages.Count > 0
? CreateMessageFromResponse(contextId, response)
: null;
await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false);
}
}
private async Task HandleNewMessageStreamingAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
{
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
// AIAgent does not support resuming from arbitrary prior tasks.
// Throw explicitly so the client gets a clear error rather than a response
// that silently ignores the referenced task context.
if (context.Message?.ReferenceTaskIds is { Count: > 0 })
{
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context.");
}
List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
var options = context.Metadata is { Count: > 0 }
? new AgentRunOptions { AdditionalProperties = context.Metadata.ToAdditionalProperties() }
: null;
try
{
await foreach (var update in this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken).ConfigureAwait(false))
{
var message = CreateMessageFromUpdate(contextId, update);
await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
}
finally
{
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
}
}
private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
{
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
List<ChatMessage> chatMessages = ExtractChatMessagesFromTaskHistory(context.Task);
var decisionContext = new A2ARunDecisionContext(context);
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
var options = context.Metadata is not { Count: > 0 }
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
AgentResponse response;
try
{
response = await this._hostAgent.RunAsync(
chatMessages,
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
var failUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false);
throw;
}
finally
{
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
}
if (response.ContinuationToken is null)
{
// Complete the task with an artifact containing the response.
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
await taskUpdater.AddArtifactAsync(response.Messages.ToParts(), cancellationToken: cancellationToken).ConfigureAwait(false);
await taskUpdater.CompleteAsync(message: null, cancellationToken).ConfigureAwait(false);
}
else
{
// Still working: emit progress status.
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
Message? progressMessage = response.Messages.Count > 0
? CreateMessageFromResponse(contextId, response)
: null;
await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false);
}
}
private static Message CreateMessageFromResponse(string contextId, AgentResponse response) =>
new()
{
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = Role.Agent,
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
private static Message CreateMessageFromUpdate(string contextId, AgentResponseUpdate update) =>
new()
{
MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = Role.Agent,
Parts = update.ToParts(),
Metadata = update.AdditionalProperties?.ToA2AMetadata()
};
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask? agentTask)
{
if (agentTask?.History is not { Count: > 0 })
{
return [];
}
var chatMessages = new List<ChatMessage>(agentTask.History.Count);
foreach (var message in agentTask.History)
{
chatMessages.Add(message.ToChatMessage());
}
return chatMessages;
}
}
@@ -9,13 +9,13 @@ namespace Microsoft.Agents.AI.Hosting.A2A;
/// </summary>
public sealed class A2ARunDecisionContext
{
internal A2ARunDecisionContext(RequestContext requestContext)
internal A2ARunDecisionContext(MessageSendParams messageSendParams)
{
this.RequestContext = requestContext;
this.MessageSendParams = messageSendParams;
}
/// <summary>
/// Gets the request context of the incoming A2A request that triggered this run.
/// Gets the parameters of the incoming A2A message that triggered this run.
/// </summary>
public RequestContext RequestContext { get; }
public MessageSendParams MessageSendParams { get; }
}
@@ -1,30 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using A2A;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Options for configuring A2A server registration.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public sealed class A2AServerRegistrationOptions
{
/// <summary>
/// Gets or sets the agent run mode that controls how the agent responds to A2A requests.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, defaults to <see cref="AgentRunMode.DisallowBackground"/>.
/// </remarks>
public AgentRunMode? AgentRunMode { get; set; }
/// <summary>
/// Gets or sets the A2A server options used to configure the underlying <see cref="A2AServer"/>.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, no custom server options are applied.
/// </remarks>
public A2AServerOptions? ServerOptions { get; set; }
}
@@ -1,160 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using A2A;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.A2A;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Provides extension methods for registering A2A server instances in the dependency injection container.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class A2AServerServiceCollectionExtensions
{
/// <summary>
/// Registers an <see cref="A2AServer"/> in the dependency injection container, keyed by the agent name
/// specified in the <paramref name="agentBuilder"/>. This method only registers the server; to expose it
/// as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping
/// methods during application startup.
/// </summary>
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
agentBuilder.ServiceCollection.AddA2AServer(agentBuilder.Name, configureOptions);
return agentBuilder;
}
/// <summary>
/// Registers an <see cref="A2AServer"/> in the dependency injection container, keyed by the specified
/// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the
/// <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
/// </summary>
/// <param name="builder">The host application builder to configure.</param>
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddA2AServer(agentName, configureOptions);
return builder;
}
/// <summary>
/// Registers an <see cref="A2AServer"/> in the dependency injection container for the specified
/// <see cref="AIAgent"/> instance, keyed by the agent's <see cref="AIAgent.Name"/>. This method only
/// registers the server; to expose it as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or
/// <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
/// </summary>
/// <param name="builder">The host application builder to configure.</param>
/// <param name="agent">The agent instance to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddA2AServer(agent, configureOptions);
return builder;
}
/// <summary>
/// Registers an <see cref="A2AServer"/> in the dependency injection container, keyed by the specified
/// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the
/// <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
/// </summary>
/// <param name="services">The service collection to add the A2A server to.</param>
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="services"/> for chaining.</returns>
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
A2AServerRegistrationOptions? options = null;
if (configureOptions is not null)
{
options = new A2AServerRegistrationOptions();
configureOptions(options);
}
services.AddKeyedSingleton(agentName, (sp, _) =>
{
var agent = sp.GetRequiredKeyedService<AIAgent>(agentName);
return CreateA2AServer(sp, agent, options);
});
return services;
}
/// <summary>
/// Registers an <see cref="A2AServer"/> in the dependency injection container for the specified
/// <see cref="AIAgent"/> instance, keyed by the agent's <see cref="AIAgent.Name"/>. This method only
/// registers the server; to expose it as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or
/// <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
/// </summary>
/// <param name="services">The service collection to add the A2A server to.</param>
/// <param name="agent">The agent instance to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="services"/> for chaining.</returns>
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
A2AServerRegistrationOptions? options = null;
if (configureOptions is not null)
{
options = new A2AServerRegistrationOptions();
configureOptions(options);
}
services.AddKeyedSingleton(agent.Name, (sp, _) => CreateA2AServer(sp, agent, options));
return services;
}
private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options)
{
var agentHandler = serviceProvider.GetKeyedService<IAgentHandler>(agent.Name);
if (agentHandler is null)
{
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
var hostAgent = new AIHostAgent(
innerAgent: agent,
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
agentHandler = new A2AAgentHandler(hostAgent, runMode);
}
var loggerFactory = serviceProvider.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
var taskStore = serviceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
return new A2AServer(
agentHandler,
taskStore,
new ChannelEventNotifier(),
loggerFactory.CreateLogger<A2AServer>(),
options?.ServerOptions);
}
}
@@ -0,0 +1,309 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.Converters;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an <see cref="AIAgent"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class AIAgentExtensions
{
// Metadata key used to store continuation tokens for long-running background operations
// in the AgentTask.Metadata dictionary, persisted by the task store.
private const string ContinuationTokenMetadataKey = "__a2a__continuationToken";
/// <summary>
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
/// </summary>
/// <param name="agent">Agent to attach A2A messaging processing capabilities to.</param>
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
/// <param name="runMode">Controls the response behavior of the agent run.</param>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
/// <returns>The configured <see cref="TaskManager"/>.</returns>
public static ITaskManager MapA2A(
this AIAgent agent,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null,
AgentRunMode? runMode = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(agent.Name);
runMode ??= AgentRunMode.DisallowBackground;
var hostAgent = new AIHostAgent(
innerAgent: agent,
sessionStore: agentSessionStore ?? new NoopAgentSessionStore());
taskManager ??= new TaskManager();
// Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent.
JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions;
// OnMessageReceived handles both message-only and task-based flows.
// The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set,
// so we consolidate all initial message handling here and return either
// an AgentMessage or AgentTask depending on the agent response.
// When the agent returns a ContinuationToken (long-running operation), a task is
// created for stateful tracking. Otherwise a lightweight AgentMessage is returned.
// See https://github.com/a2aproject/a2a-dotnet/issues/275
taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct);
// Task flow for subsequent updates and cancellations
taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct);
taskManager.OnTaskCancelled += OnTaskCancelledAsync;
return taskManager;
}
/// <summary>
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
/// </summary>
/// <param name="agent">Agent to attach A2A messaging processing capabilities to.</param>
/// <param name="agentCard">The agent card to return on query.</param>
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
/// <param name="runMode">Controls the response behavior of the agent run.</param>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
/// <returns>The configured <see cref="TaskManager"/>.</returns>
public static ITaskManager MapA2A(
this AIAgent agent,
AgentCard agentCard,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null,
AgentRunMode? runMode = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions);
taskManager.OnAgentCardQuery += (context, query) =>
{
// A2A SDK assigns the url on its own
// we can help user if they did not set Url explicitly.
if (string.IsNullOrEmpty(agentCard.Url))
{
agentCard.Url = context.TrimEnd('/');
}
return Task.FromResult(agentCard);
};
return taskManager;
}
private static async Task<A2AResponse> OnMessageReceivedAsync(
MessageSendParams messageSendParams,
AIHostAgent hostAgent,
AgentRunMode runMode,
ITaskManager taskManager,
JsonSerializerOptions continuationTokenJsonOptions,
CancellationToken cancellationToken)
{
// AIAgent does not support resuming from arbitrary prior tasks.
// Throw explicitly so the client gets a clear error rather than a response
// that silently ignores the referenced task context.
// Follow-ups on the *same* task are handled via OnTaskUpdated instead.
if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 })
{
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task.");
}
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
// Decide whether to run in background based on user preferences and agent capabilities
var decisionContext = new A2ARunDecisionContext(messageSendParams);
var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
var options = messageSendParams.Metadata is not { Count: > 0 }
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is null)
{
return CreateMessageFromResponse(contextId, response);
}
var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false);
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
return agentTask;
}
private static async Task OnTaskUpdatedAsync(
AgentTask agentTask,
AIHostAgent hostAgent,
ITaskManager taskManager,
JsonSerializerOptions continuationTokenJsonOptions,
CancellationToken cancellationToken)
{
var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
try
{
// Discard any stale continuation token — the incoming user message supersedes
// any previous background operation. AF agents don't support updating existing
// background responses (long-running operations); we start a fresh run from the
// existing session using the full chat history (which includes the new message).
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false);
var response = await hostAgent.RunAsync(
ExtractChatMessagesFromTaskHistory(agentTask),
session: session,
options: new AgentRunOptions { AllowBackgroundResponses = true },
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is not null)
{
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
}
else
{
await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
await taskManager.UpdateStatusAsync(
agentTask.Id,
TaskState.Failed,
final: true,
cancellationToken: cancellationToken).ConfigureAwait(false);
throw;
}
}
private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken)
{
// Remove the continuation token from metadata if present.
// The task has already been marked as cancelled by the TaskManager.
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
return Task.CompletedTask;
}
private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) =>
new()
{
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
// Task outputs should be returned as artifacts rather than messages:
// https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts
private static Artifact CreateArtifactFromResponse(AgentResponse response) =>
new()
{
ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
private static async Task<AgentTask> InitializeTaskAsync(
string contextId,
AgentMessage originalMessage,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false);
// Add the original user message to the task history.
// The A2A SDK does this internally when it creates tasks via OnTaskCreated.
agentTask.History ??= [];
agentTask.History.Add(originalMessage);
// Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false);
return agentTask;
}
private static void StoreContinuationToken(
AgentTask agentTask,
ResponseContinuationToken token,
JsonSerializerOptions continuationTokenJsonOptions)
{
// Serialize the continuation token into the task's metadata so it survives
// across requests and is cleaned up with the task itself.
agentTask.Metadata ??= [];
agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement(
token,
continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
}
private static async Task TransitionToWorkingAsync(
string taskId,
string contextId,
AgentResponse response,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
// Include any intermediate progress messages from the response as a status message.
AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null;
await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task CompleteWithArtifactAsync(
string taskId,
AgentResponse response,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
var artifact = CreateArtifactFromResponse(response);
await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false);
await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask agentTask)
{
if (agentTask.History is not { Count: > 0 })
{
return [];
}
var chatMessages = new List<ChatMessage>(agentTask.History.Count);
foreach (var message in agentTask.History)
{
chatMessages.Add(message.ToChatMessage());
}
return chatMessages;
}
}
@@ -2,7 +2,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
@@ -29,7 +28,7 @@ public sealed class AgentRunMode : IEquatable<AgentRunMode>
}
/// <summary>
/// Disallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
/// Dissallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentMessage</c>.
/// </summary>
public static AgentRunMode DisallowBackground => new(MessageValue);
@@ -80,22 +79,18 @@ public sealed class AgentRunMode : IEquatable<AgentRunMode>
}
// No delegate provided — fall back to "message" behavior.
return ValueTask.FromResult(false);
return ValueTask.FromResult(true);
}
/// <inheritdoc/>
public bool Equals(AgentRunMode? other) =>
other is not null
&& string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase)
&& ReferenceEquals(this._runInBackground, other._runInBackground);
other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(
StringComparer.OrdinalIgnoreCase.GetHashCode(this._value),
RuntimeHelpers.GetHashCode(this._runInBackground));
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value);
/// <inheritdoc/>
public override string ToString() => this._value;
@@ -8,26 +8,6 @@ namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
internal static class MessageConverter
{
public static List<Part> ToParts(this AgentResponseUpdate update)
{
if (update is null || update.Contents is not { Count: > 0 })
{
return [];
}
var parts = new List<Part>();
foreach (var content in update.Contents)
{
var part = content.ToPart();
if (part is not null)
{
parts.Add(part);
}
}
return parts;
}
public static List<Part> ToParts(this IList<ChatMessage> chatMessages)
{
if (chatMessages is null || chatMessages.Count == 0)
@@ -51,21 +31,21 @@ internal static class MessageConverter
return parts;
}
/// <summary>
/// Converts A2A SendMessageRequest to a collection of Microsoft.Extensions.AI ChatMessage objects.
/// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects.
/// </summary>
/// <param name="sendMessageRequest">The A2A send message request to convert.</param>
/// <param name="messageSendParams">The A2A message send parameters to convert.</param>
/// <returns>A read-only collection of ChatMessage objects.</returns>
public static List<ChatMessage> ToChatMessages(this SendMessageRequest sendMessageRequest)
public static List<ChatMessage> ToChatMessages(this MessageSendParams messageSendParams)
{
if (sendMessageRequest is null)
if (messageSendParams is null)
{
return [];
}
var result = new List<ChatMessage>();
if (sendMessageRequest.Message?.Parts is not null)
if (messageSendParams.Message?.Parts is not null)
{
result.Add(sendMessageRequest.Message.ToChatMessage());
result.Add(messageSendParams.Message.ToChatMessage());
}
return result;
@@ -254,7 +254,7 @@ internal static class SemanticAnalyzer
/// <summary>
/// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have IO attributes
/// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsOutput calls in the protocol
/// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsMessage calls in the protocol
/// configuration.
/// </summary>
/// <remarks>
@@ -9,6 +9,17 @@ namespace Microsoft.Agents.AI.Workflows;
internal static class AIAgentsAbstractionsExtensions
{
public static ChatMessage ToChatMessage(this AgentResponseUpdate update) =>
new()
{
AuthorName = update.AuthorName,
Contents = update.Contents,
Role = update.Role ?? ChatRole.User,
CreatedAt = update.CreatedAt,
MessageId = update.MessageId,
RawRepresentation = update.RawRepresentation ?? update,
};
public static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName)
=> message.ChatAssistantToUserIfNotFromNamed(agentName, out _, false);
@@ -29,7 +29,6 @@ namespace Microsoft.Agents.AI.Workflows;
/// }
/// </code>
/// </example>
[Obsolete("Use YieldsOutput instead. The Code Generator and the runtime attribute-based type mapping ignore this attribute.")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class YieldsMessageAttribute : Attribute
{
@@ -48,25 +47,3 @@ public sealed class YieldsMessageAttribute : Attribute
this.Type = Throw.IfNull(type);
}
}
/// <summary>
/// This attribute indicates that a message handler streams messages during its execution.
/// </summary>
[Obsolete("This attribute does not do anything. The Code Generator and the runtime attribute-based type mapping ignore this attribute.")]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class StreamsMessageAttribute : Attribute
{
/// <summary>
/// The type of the message that the handler yields.
/// </summary>
public Type Type { get; }
/// <summary>
/// Indicates that the message handler yields streaming messages during the course of execution.
/// </summary>
public StreamsMessageAttribute(Type type)
{
// This attribute is used to mark executors that yield messages.
this.Type = Throw.IfNull(type);
}
}
@@ -47,7 +47,7 @@ public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOpti
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => context.SendMessageAsync(new ChatMessage(this._stringMessageChatRole.Value, message)));
(message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message)));
}
routeBuilder.AddHandler<ChatMessage>(ForwardMessageAsync)
@@ -77,13 +77,12 @@ internal sealed class StreamingRunEventStream : IRunEventStream
try
{
// Wait for the first input before starting.
// The consumer will call EnqueueMessageAsync which signals the run loop.
// Note: AsyncRunHandle also signals here on checkpoint resume when there are
// already pending requests, so the first iteration can emit a PendingRequests
// halt signal even without unprocessed messages.
// Wait for the first input before starting
// The consumer will call EnqueueMessageAsync which signals the run loop
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
this._runStatus = RunStatus.Running;
while (!linkedSource.Token.IsCancellationRequested)
{
// Start a new run-stage activity for this input→processing→halt cycle
@@ -96,13 +95,6 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Events are streamed out in real-time as they happen via the event handler
if (this._stepRunner.HasUnprocessedMessages)
{
// Flip to Running only when there's actual work to process.
// This is intentionally inside the HasUnprocessedMessages branch so
// that stale input signals cannot transiently flip status back to
// Running after a prior halt has already been observed by callers
// (e.g. Run.ResumeAsync returning after reading an Idle halt signal).
this._runStatus = RunStatus.Running;
// Emit WorkflowStartedEvent only when there's actual work to process
// This avoids spurious events on timeout-only loop iterations
await this._eventChannel.Writer.WriteAsync(new WorkflowStartedEvent(), linkedSource.Token).ConfigureAwait(false);
@@ -137,6 +129,9 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Wait for next input from the consumer
// Works for both Idle (no work) and PendingRequests (waiting for responses)
await this._inputWaiter.WaitForInputAsync(linkedSource.Token).ConfigureAwait(false);
// When signaled, resume running
this._runStatus = RunStatus.Running;
}
}
catch (OperationCanceledException)
@@ -73,14 +73,7 @@ public class FunctionExecutor<TInput>(string id,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : this(id,
WrapAction(handlerSync,
out var attributeSentTypes,
out var attributeYieldTypes),
options,
attributeSentTypes.Concat(sentMessageTypes ?? []),
attributeYieldTypes.Concat(outputTypes ?? []),
declareCrossRunShareable)
bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync, out var attributeSentTypes, out var attributeYieldTypes), options, attributeSentTypes.Concat(sentMessageTypes ?? []), attributeYieldTypes.Concat(outputTypes ?? []), declareCrossRunShareable)
{
}
}
@@ -103,18 +96,8 @@ public class FunctionExecutor<TInput, TOutput>(string id,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : Executor<TInput, TOutput>(id, options, declareCrossRunShareable)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> WrapFunc(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync, out IEnumerable<Type> sentTypes, out IEnumerable<Type> yieldedTypes)
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> WrapFunc(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync)
{
if (handlerSync.Method != null)
{
MethodInfo method = handlerSync.Method;
(sentTypes, yieldedTypes) = method.GetAttributeTypes();
}
else
{
sentTypes = yieldedTypes = [];
}
return RunFuncAsync;
ValueTask<TOutput> RunFuncAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellationToken)
@@ -150,14 +133,7 @@ public class FunctionExecutor<TInput, TOutput>(string id,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : this(id,
WrapFunc(handlerSync,
out var attributeSentTypes,
out var attributeYieldTypes),
options,
attributeSentTypes.Concat(sentMessageTypes ?? []),
attributeYieldTypes.Concat(outputTypes ?? []),
declareCrossRunShareable)
bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, sentMessageTypes, outputTypes, declareCrossRunShareable)
{
}
}
@@ -20,7 +20,6 @@ internal static class DiagnosticConstants
}
/// <inheritdoc/>
[ExcludeFromCodeCoverage] // This is obsolete, and 1:1 equivalent to HandoffWorkflowBuilder (no "s")
[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")]
#pragma warning disable MAAIW001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffsWorkflowBuilder>(initialAgent)
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// This attribute indicates that a message handler streams messages during its execution.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class StreamsMessageAttribute : Attribute
{
/// <summary>
/// The type of the message that the handler yields.
/// </summary>
public Type Type { get; }
/// <summary>
/// Indicates that the message handler yields streaming messages during the course of execution.
/// </summary>
public StreamsMessageAttribute(Type type)
{
// This attribute is used to mark executors that yield messages.
this.Type = Throw.IfNull(type);
}
}
File diff suppressed because it is too large Load Diff
@@ -106,18 +106,6 @@ public sealed class A2AContinuationTokenTests
Assert.Throws<ArgumentException>(() => A2AContinuationToken.FromToken(emptyToken));
}
[Fact]
public void FromToken_WithNullTaskIdValue_ThrowsJsonException()
{
// Arrange
var jsonWithNullTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"taskId\": null }").AsMemory();
var mockToken = new MockResponseContinuationToken(jsonWithNullTaskId);
// Act & Assert
var ex = Assert.Throws<JsonException>(() => A2AContinuationToken.FromToken(mockToken));
Assert.Contains("taskId", ex.Message);
}
[Fact]
public void FromToken_WithMissingTaskIdProperty_ThrowsException()
{
@@ -42,14 +42,14 @@ public sealed class A2AAIContentExtensionsTests
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.Equal(PartContentCase.Text, result[0].ContentCase);
Assert.Equal("First text", result[0].Text);
var firstTextPart = Assert.IsType<TextPart>(result[0]);
Assert.Equal("First text", firstTextPart.Text);
Assert.Equal(PartContentCase.Url, result[1].ContentCase);
Assert.Equal("https://example.com/file1.txt", result[1].Url);
var filePart = Assert.IsType<FilePart>(result[1]);
Assert.Equal("https://example.com/file1.txt", filePart.File.Uri?.ToString());
Assert.Equal(PartContentCase.Text, result[2].ContentCase);
Assert.Equal("Second text", result[2].Text);
var secondTextPart = Assert.IsType<TextPart>(result[2]);
Assert.Equal("Second text", secondTextPart.Text);
}
[Fact]
@@ -72,14 +72,14 @@ public sealed class A2AAIContentExtensionsTests
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.Equal(PartContentCase.Text, result[0].ContentCase);
Assert.Equal("First text", result[0].Text);
var firstTextPart = Assert.IsType<TextPart>(result[0]);
Assert.Equal("First text", firstTextPart.Text);
Assert.Equal(PartContentCase.Url, result[1].ContentCase);
Assert.Equal("https://example.com/file.txt", result[1].Url);
var filePart = Assert.IsType<FilePart>(result[1]);
Assert.Equal("https://example.com/file.txt", filePart.File.Uri?.ToString());
Assert.Equal(PartContentCase.Text, result[2].ContentCase);
Assert.Equal("Second text", result[2].Text);
var secondTextPart = Assert.IsType<TextPart>(result[2]);
Assert.Equal("Second text", secondTextPart.Text);
}
// Mock class for testing unsupported scenarios
@@ -26,7 +26,7 @@ public sealed class A2AAgentCardExtensionsTests
{
Name = "Test Agent",
Description = "A test agent for unit testing",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
Url = "http://test-endpoint/agent"
};
}
@@ -50,13 +50,13 @@ public sealed class A2AAgentCardExtensionsTests
using var handler = new HttpMessageHandlerStub();
using var httpClient = new HttpClient(handler, false);
handler.ResponsesToReturn.Enqueue(new Message
handler.ResponsesToReturn.Enqueue(new AgentMessage
{
Role = Role.Agent,
Parts = [Part.FromText("Response")],
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Response" }],
});
var agent = this._agentCard.AsAIAgent(httpClient: httpClient);
var agent = this._agentCard.AsAIAgent(httpClient);
// Act
await agent.RunAsync("Test input");
@@ -66,105 +66,6 @@ public sealed class A2AAgentCardExtensionsTests
Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]);
}
[Fact]
public async Task AsAIAgent_WithPreferredBindings_UsesMatchingInterfaceAsync()
{
// Arrange
var card = new AgentCard
{
Name = "Multi-Interface Agent",
Description = "An agent with multiple interfaces",
SupportedInterfaces =
[
new AgentInterface { Url = "http://first/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
new AgentInterface { Url = "http://second/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
]
};
using var handler = new HttpMessageHandlerStub();
using var httpClient = new HttpClient(handler, false);
handler.ResponsesToReturn.Enqueue(new Message
{
Role = Role.Agent,
Parts = [Part.FromText("Response")],
});
var options = new A2AClientOptions
{
PreferredBindings = [ProtocolBindingNames.JsonRpc]
};
var agent = card.AsAIAgent(httpClient, options: options);
// Act
await agent.RunAsync("Test input");
// Assert
Assert.Single(handler.CapturedUris);
Assert.Equal(new Uri("http://second/agent"), handler.CapturedUris[0]);
}
[Fact]
public void AsAIAgent_WithNullOptions_UsesDefaultBindingPreference()
{
// Arrange
var card = new AgentCard
{
Name = "Default Options Agent",
Description = "Tests default A2AClientOptions behavior",
SupportedInterfaces =
[
new AgentInterface { Url = "http://default/agent" },
]
};
// Act - null options should use defaults (HTTP+JSON first, JSON-RPC as fallback)
var agent = card.AsAIAgent(options: null);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("Default Options Agent", agent.Name);
}
[Fact]
public void AsAIAgent_WithNoMatchingBinding_ThrowsException()
{
// Arrange
var card = new AgentCard
{
Name = "Unmatched Binding Agent",
Description = "Agent with unsupported binding only",
SupportedInterfaces =
[
new AgentInterface { Url = "http://grpc/agent", ProtocolBinding = "GRPC" },
]
};
var options = new A2AClientOptions
{
PreferredBindings = [ProtocolBindingNames.JsonRpc]
};
// Act & Assert - factory should throw when no matching binding exists
Assert.ThrowsAny<Exception>(() => card.AsAIAgent(options: options));
}
[Fact]
public void AsAIAgent_WithNoSupportedInterfaces_ThrowsException()
{
// Arrange
var card = new AgentCard
{
Name = "No Interfaces Agent",
Description = "Agent with no supported interfaces",
};
// Act & Assert
Assert.ThrowsAny<Exception>(() => card.AsAIAgent());
}
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
{
public Queue ResponsesToReturn { get; } = new();
@@ -185,18 +86,13 @@ public sealed class A2AAgentCardExtensionsTests
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
else if (response is Message message)
else if (response is AgentMessage message)
{
var sendMessageResponse = new SendMessageResponse { Message = message };
var jsonRpcResponse = new JsonRpcResponse
{
Id = "response-id",
Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions)
};
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", message);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json")
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
};
}
@@ -40,7 +40,7 @@ public sealed class A2AAgentTaskExtensionsTests
{
Id = "task1",
Artifacts = [],
Status = new TaskStatus { State = TaskState.Completed },
Status = new AgentTaskStatus { State = TaskState.Completed },
};
// Act
@@ -58,7 +58,7 @@ public sealed class A2AAgentTaskExtensionsTests
{
Id = "task1",
Artifacts = null,
Status = new TaskStatus { State = TaskState.Completed },
Status = new AgentTaskStatus { State = TaskState.Completed },
};
// Act
@@ -76,7 +76,7 @@ public sealed class A2AAgentTaskExtensionsTests
{
Id = "task1",
Artifacts = [],
Status = new TaskStatus { State = TaskState.Completed },
Status = new AgentTaskStatus { State = TaskState.Completed },
};
// Act
@@ -94,7 +94,7 @@ public sealed class A2AAgentTaskExtensionsTests
{
Id = "task1",
Artifacts = null,
Status = new TaskStatus { State = TaskState.Completed },
Status = new AgentTaskStatus { State = TaskState.Completed },
};
// Act
@@ -110,14 +110,14 @@ public sealed class A2AAgentTaskExtensionsTests
// Arrange
var artifact = new Artifact
{
Parts = [Part.FromText("response")],
Parts = [new TextPart { Text = "response" }],
};
var agentTask = new AgentTask
{
Id = "task1",
Artifacts = [artifact],
Status = new TaskStatus { State = TaskState.Completed },
Status = new AgentTaskStatus { State = TaskState.Completed },
};
// Act
@@ -136,15 +136,15 @@ public sealed class A2AAgentTaskExtensionsTests
// Arrange
var artifact1 = new Artifact
{
Parts = [Part.FromText("content1")],
Parts = [new TextPart { Text = "content1" }],
};
var artifact2 = new Artifact
{
Parts =
[
Part.FromText("content2"),
Part.FromText("content3")
new TextPart { Text = "content2" },
new TextPart { Text = "content3" }
],
};
@@ -152,7 +152,7 @@ public sealed class A2AAgentTaskExtensionsTests
{
Id = "task1",
Artifacts = [artifact1, artifact2],
Status = new TaskStatus { State = TaskState.Completed },
Status = new AgentTaskStatus { State = TaskState.Completed },
};
// Act
@@ -22,9 +22,9 @@ public sealed class A2AArtifactExtensionsTests
Name = "comprehensive-artifact",
Parts =
[
Part.FromText("First part"),
Part.FromText("Second part"),
Part.FromText("Third part")
new TextPart { Text = "First part" },
new TextPart { Text = "Second part" },
new TextPart { Text = "Third part" }
],
Metadata = new Dictionary<string, JsonElement>
{
@@ -66,9 +66,9 @@ public sealed class A2AArtifactExtensionsTests
Name = "test",
Parts =
[
Part.FromText("Part 1"),
Part.FromText("Part 2"),
Part.FromText("Part 3")
new TextPart { Text = "Part 1" },
new TextPart { Text = "Part 2" },
new TextPart { Text = "Part 3" }
],
Metadata = null
};
@@ -37,7 +37,7 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
{
Name = "Test Agent",
Description = "A test agent for unit testing",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
Url = "http://test-endpoint/agent"
});
// Act
@@ -60,15 +60,15 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
// Arrange
this._handler.ResponsesToReturn.Enqueue(new AgentCard
{
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
Url = "http://test-endpoint/agent"
});
this._handler.ResponsesToReturn.Enqueue(new Message
this._handler.ResponsesToReturn.Enqueue(new AgentMessage
{
Role = Role.Agent,
Parts = [Part.FromText("Response")],
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Response" }],
});
var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient);
var agent = await this._resolver.GetAIAgentAsync(this._httpClient);
// Act
await agent.RunAsync("Test input");
@@ -78,41 +78,6 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]);
}
[Fact]
public async Task GetAIAgentAsync_WithOptions_PassesOptionsToFactoryAsync()
{
// Arrange
this._handler.ResponsesToReturn.Enqueue(new AgentCard
{
Name = "Options Agent",
Description = "Agent with multiple interfaces",
SupportedInterfaces =
[
new AgentInterface { Url = "http://httpjson/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
new AgentInterface { Url = "http://jsonrpc/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
]
});
this._handler.ResponsesToReturn.Enqueue(new Message
{
Role = Role.Agent,
Parts = [Part.FromText("Response")],
});
var options = new A2AClientOptions
{
PreferredBindings = [ProtocolBindingNames.JsonRpc]
};
var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient, options: options);
// Act
await agent.RunAsync("Test input");
// Assert
Assert.Equal(2, this._handler.CapturedUris.Count);
Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]);
}
public void Dispose()
{
this._handler.Dispose();
@@ -139,18 +104,13 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
else if (response is Message message)
else if (response is AgentMessage message)
{
var sendMessageResponse = new SendMessageResponse { Message = message };
var jsonRpcResponse = new JsonRpcResponse
{
Id = "response-id",
Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions)
};
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", message);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json")
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
};
}
@@ -30,40 +30,4 @@ public sealed class A2AClientExtensionsTests
Assert.Equal(TestName, agent.Name);
Assert.Equal(TestDescription, agent.Description);
}
[Fact]
public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
{
// Arrange - use IA2AClient reference type to verify the extension method works with the interface
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
const string TestId = "ia2a-agent-id";
const string TestName = "IA2A Agent";
const string TestDescription = "Agent created from IA2AClient";
// Act
var agent = a2aClient.AsAIAgent(TestId, TestName, TestDescription);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal(TestId, agent.Id);
Assert.Equal(TestName, agent.Name);
Assert.Equal(TestDescription, agent.Description);
}
[Fact]
public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService()
{
// Arrange
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
// Act
var agent = a2aClient.AsAIAgent();
// Assert
var service = agent.GetService(typeof(IA2AClient));
Assert.NotNull(service);
Assert.Same(a2aClient, service);
}
}
@@ -32,19 +32,20 @@ public sealed class ChatMessageExtensionsTests
Assert.NotNull(a2aMessage.MessageId);
Assert.NotEmpty(a2aMessage.MessageId);
Assert.Equal(Role.User, a2aMessage.Role);
Assert.Equal(MessageRole.User, a2aMessage.Role);
Assert.NotNull(a2aMessage.Parts);
Assert.Equal(3, a2aMessage.Parts.Count);
Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase);
Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url);
var filePart = Assert.IsType<FilePart>(a2aMessage.Parts[0]);
Assert.NotNull(filePart.File);
Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString());
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase);
Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text);
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
Assert.Equal("please summarize the file content", secondTextPart.Text);
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase);
Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text);
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
Assert.Equal("and send it to me over email", thirdTextPart.Text);
}
[Fact]
@@ -70,18 +71,19 @@ public sealed class ChatMessageExtensionsTests
Assert.NotNull(a2aMessage.MessageId);
Assert.NotEmpty(a2aMessage.MessageId);
Assert.Equal(Role.User, a2aMessage.Role);
Assert.Equal(MessageRole.User, a2aMessage.Role);
Assert.NotNull(a2aMessage.Parts);
Assert.Equal(3, a2aMessage.Parts.Count);
Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase);
Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url);
var filePart = Assert.IsType<FilePart>(a2aMessage.Parts[0]);
Assert.NotNull(filePart.File);
Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString());
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase);
Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text);
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
Assert.Equal("please summarize the file content", secondTextPart.Text);
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase);
Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text);
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
Assert.Equal("and send it to me over email", thirdTextPart.Text);
}
}
@@ -1,9 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
</ItemGroup>
@@ -164,8 +164,10 @@ public class AgentFrameworkResponseHandlerTelemetryTests
private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null)
{
var request = agentKey is null
? new CreateResponse { Model = "test" }
: new CreateResponse { Model = "test", AgentReference = new AgentReference(agentKey) };
? AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test")
: AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference(agentKey));
request.Input = BinaryData.FromObjectAsJson(new[]
{
@@ -34,7 +34,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -72,7 +72,9 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-agent") };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("my-agent"));
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -107,7 +109,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -156,7 +158,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "my-agent" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -193,7 +195,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
var metadata = new Metadata();
metadata.AdditionalProperties["entity_id"] = "entity-agent";
request.Metadata = metadata;
@@ -233,7 +235,9 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("nonexistent-agent") };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("nonexistent-agent"));
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -268,7 +272,9 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("missing-agent") };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("missing-agent"));
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -302,7 +308,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -336,7 +342,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -381,7 +387,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -429,7 +435,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -472,7 +478,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -511,11 +517,9 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse
{
Model = "test",
Instructions = "You are a helpful assistant.",
};
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
instructions: "You are a helpful assistant.");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -553,7 +557,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -594,7 +598,9 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("agent-2") };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("agent-2"));
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -631,7 +637,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -668,7 +674,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -1,329 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for the <see cref="FoundryToolbox"/> class.
/// </summary>
public class FoundryToolboxTests
{
private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project");
#region Parameter validation tests
[Fact]
public async Task GetToolboxVersionAsync_NullEndpoint_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: null!,
credential: new FakeAuthenticationTokenProvider(),
name: "test-toolbox"));
}
[Fact]
public async Task GetToolboxVersionAsync_NullCredential_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: s_testEndpoint,
credential: null!,
name: "test-toolbox"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task GetToolboxVersionAsync_InvalidName_ThrowsAsync(string? name)
{
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: s_testEndpoint,
credential: new FakeAuthenticationTokenProvider(),
name: name!));
}
[Fact]
public async Task GetToolsAsync_NullEndpoint_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolsAsync(
projectEndpoint: null!,
credential: new FakeAuthenticationTokenProvider(),
name: "test-toolbox"));
}
[Fact]
public void ToAITools_NullToolboxVersion_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
FoundryToolbox.ToAITools(null!));
}
#endregion
#region ToAITools conversion tests
[Fact]
public void ToAITools_EmptyTools_ReturnsEmptyList()
{
var version = ProjectsAgentsModelFactory.ToolboxVersion(
metadata: null,
id: "ver-1",
name: "empty-toolbox",
version: "v1",
description: "Empty",
createdAt: DateTimeOffset.UtcNow,
tools: Array.Empty<ProjectsAgentTool>(),
policies: null);
var tools = version.ToAITools();
Assert.Empty(tools);
}
[Fact]
public void ToAITools_NullTools_ReturnsEmptyList()
{
var version = ProjectsAgentsModelFactory.ToolboxVersion(
metadata: null,
id: "ver-1",
name: "null-tools-toolbox",
version: "v1",
description: "Null tools",
createdAt: DateTimeOffset.UtcNow,
tools: null,
policies: null);
var tools = version.ToAITools();
Assert.Empty(tools);
}
[Fact]
public void ToAITools_WithCodeInterpreterTool_ReturnsAITool()
{
var json = TestDataUtil.GetToolboxVersionResponseJson();
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
var tools = version.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
[Fact]
public void ToAITools_SanitizesDecorationFieldsOnNonFunctionTools()
{
var json = TestDataUtil.GetToolboxVersionWithDecorationFieldsJson();
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
var tools = version.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
[Fact]
public void SanitizeAndConvert_FunctionTool_PreservesNameAndDescription()
{
const string ToolJson = @"{""type"":""function"",""name"":""get_weather"",""description"":""Get weather"",""parameters"":{""type"":""object"",""properties"":{}}}";
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
Assert.NotNull(aiTool);
Assert.IsAssignableFrom<AITool>(aiTool);
}
[Fact]
public void SanitizeAndConvert_CodeInterpreterWithExtraFields_StripsDecorationFields()
{
const string ToolJson = @"{""type"":""code_interpreter"",""name"":""code_interpreter"",""description"":""Execute code""}";
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
Assert.NotNull(aiTool);
}
#endregion
#region Integration tests with mock HTTP
[Fact]
public async Task GetToolboxVersionAsync_WithExplicitVersion_FetchesVersionDirectlyAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((request) =>
{
Assert.Contains("/toolboxes/research_tools/versions/v5", request.RequestUri!.PathAndQuery);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
};
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: "v5",
clientOptions: clientOptions,
cancellationToken: default);
Assert.Equal("research_tools", result.Name);
Assert.Equal("v5", result.Version);
Assert.Single(result.Tools);
}
[Fact]
public async Task GetToolboxVersionAsync_WithoutVersion_ResolvesDefaultThenFetchesAsync()
{
var recordJson = TestDataUtil.GetToolboxRecordResponseJson();
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
var callCount = 0;
using var httpHandler = new HttpHandlerAssert((request) =>
{
callCount++;
var path = request.RequestUri!.PathAndQuery;
if (!path.Contains("/versions/"))
{
Assert.Contains("/toolboxes/research_tools", path);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(recordJson, Encoding.UTF8, "application/json")
};
}
Assert.Contains("/toolboxes/research_tools/versions/v5", path);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
};
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: null,
clientOptions: clientOptions,
cancellationToken: default);
Assert.Equal(2, callCount);
Assert.Equal("research_tools", result.Name);
Assert.Equal("v5", result.Version);
}
[Fact]
public async Task GetToolboxVersionAsync_ApiError_ThrowsClientResultExceptionAsync()
{
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("{\"error\":\"not found\"}", Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
await Assert.ThrowsAsync<ClientResultException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"nonexistent-toolbox",
version: "v1",
clientOptions: clientOptions,
cancellationToken: default));
}
[Fact]
public async Task GetToolsAsync_ReturnsConvertedAIToolsAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: "v5",
clientOptions: clientOptions,
cancellationToken: default);
var tools = result.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
#endregion
#region AIProjectClient extension tests
[Fact]
public async Task AIProjectClientExtension_GetToolboxToolsAsync_ReturnsAIToolsAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AIProjectClientOptions();
clientOptions.Transport = new HttpClientPipelineTransport(httpClient);
var client = new AIProjectClient(s_testEndpoint, new FakeAuthenticationTokenProvider(), clientOptions);
var tools = await client.GetToolboxToolsAsync("research_tools", version: "v5");
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
#endregion
}
@@ -2,6 +2,7 @@
using System;
using System.Linq;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -145,7 +146,11 @@ public class InputConverterTests
[Fact]
public void ConvertToChatOptions_SetsTemperatureAndTopP()
{
var request = new CreateResponse { Temperature = 0.7, TopP = 0.9, MaxOutputTokens = 1000, Model = "gpt-4o" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
temperature: 0.7,
topP: 0.9,
maxOutputTokens: 1000,
model: "gpt-4o");
var options = InputConverter.ConvertToChatOptions(request);
@@ -206,9 +211,9 @@ public class InputConverterTests
}
[Fact]
public void ConvertOutputItemsToMessages_FunctionToolCallOutput_ReturnsToolMessage()
public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage()
{
var funcOutput = new OutputItemFunctionToolCallOutput(
var funcOutput = new FunctionToolCallOutputResource(
callId: "call_def",
output: BinaryData.FromString("result data"));
@@ -224,7 +229,8 @@ public class InputConverterTests
[Fact]
public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull()
{
var reasoning = new OutputItemReasoningItem("reason_001", []);
var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem(
id: "reason_001");
var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]);
@@ -655,7 +661,7 @@ public class InputConverterTests
[Fact]
public void ConvertToChatOptions_ModelId_NotSetFromRequest()
{
var request = new CreateResponse { Model = "my-model" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model");
var options = InputConverter.ConvertToChatOptions(request);
@@ -20,7 +20,7 @@ public class OutputConverterTests
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
{
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
var request = new CreateResponse { Model = "test-model" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
var stream = new ResponseEventStream(mockContext.Object, request);
return (stream, mockContext);
}
@@ -160,7 +160,9 @@ public class WorkflowIntegrationTests
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("my-workflow"));
request.Input = CreateUserInput("Test keyed workflow");
var mockContext = CreateMockContext();
@@ -361,7 +363,7 @@ public class WorkflowIntegrationTests
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = CreateUserInput(userMessage);
var mockContext = CreateMockContext();
@@ -391,7 +393,7 @@ public class WorkflowIntegrationTests
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
{
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
var request = new CreateResponse { Model = "test-model" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
var stream = new ResponseEventStream(mockContext.Object, request);
return (stream, mockContext);
}
@@ -10,7 +10,7 @@
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
@@ -34,7 +34,7 @@
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="FoundryEvalConverterTests.cs" />
<Compile Remove="FoundryEvalsTests.cs" />
@@ -50,15 +50,6 @@
<None Update="TestData\OpenAIDefaultResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxRecordResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,5 +0,0 @@
{
"id": "tbx-123",
"name": "research_tools",
"default_version": "v5"
}
@@ -1,11 +0,0 @@
{
"metadata": {},
"id": "tbv-research_tools-v5",
"name": "research_tools",
"version": "v5",
"description": "Example research toolbox",
"created_at": 1775779200,
"tools": [
{ "type": "code_interpreter" }
]
}
@@ -1,11 +0,0 @@
{
"metadata": {},
"id": "tbv-dirty-v1",
"name": "dirty_toolbox",
"version": "v1",
"description": "Toolbox with decoration fields on tools",
"created_at": 1775779200,
"tools": [
{ "type": "code_interpreter", "name": "code_interpreter", "description": "Execute Python code" }
]
}
@@ -14,9 +14,6 @@ internal static class TestDataUtil
private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json");
private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json");
private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json");
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\"";
@@ -165,19 +162,4 @@ internal static class TestDataUtil
}
return json;
}
/// <summary>
/// Gets the toolbox record response JSON.
/// </summary>
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
/// <summary>
/// Gets the toolbox version response JSON.
/// </summary>
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
/// <summary>
/// Gets the toolbox version response JSON with decoration fields on tools.
/// </summary>
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
}
File diff suppressed because it is too large Load Diff
@@ -1,559 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Tests for A2AEndpointRouteBuilderExtensions and A2AServerServiceCollectionExtensions methods.
/// </summary>
public sealed class A2AEndpointRouteBuilderExtensionsTests
{
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2AHttpJson(agentBuilder, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentBuilder.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
IHostedAgentBuilder agentBuilder = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AHttpJson(agentBuilder, "/a2a"));
Assert.Equal("agentBuilder", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson with IHostedAgentBuilder correctly maps the agent with default configuration.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentBuilder_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2AHttpJson(agentBuilder, "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2AHttpJson with string agent name correctly maps the agent.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentName_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddA2AServer("agent");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2AHttpJson("agent", "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2AJsonRpc with IHostedAgentBuilder correctly maps the agent.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAgentBuilder_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2AJsonRpc(agentBuilder, "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2AJsonRpc with string agent name correctly maps the agent.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAgentName_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddA2AServer("agent");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2AJsonRpc("agent", "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that both MapA2AHttpJson and MapA2AJsonRpc can be called for the same agent.
/// </summary>
[Fact]
public void MapA2AHttpJson_And_MapA2AJsonRpc_SameAgent_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var httpResult = app.MapA2AHttpJson(agentBuilder, "/a2a");
var rpcResult = app.MapA2AJsonRpc(agentBuilder, "/a2a");
Assert.NotNull(httpResult);
Assert.NotNull(rpcResult);
}
/// <summary>
/// Verifies that multiple agents can be mapped to different paths.
/// </summary>
[Fact]
public void MapA2AHttpJson_MultipleAgents_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
agent1Builder.AddA2AServer();
agent2Builder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapA2AHttpJson(agent1Builder, "/a2a/agent1");
app.MapA2AHttpJson(agent2Builder, "/a2a/agent2");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that custom paths can be specified for A2A endpoints.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithCustomPath_AcceptsValidPath()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapA2AHttpJson(agentBuilder, "/custom/a2a/path");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that AddA2AServer with custom A2AServerRegistrationOptions succeeds.
/// </summary>
[Fact]
public void AddA2AServer_WithCustomOptions_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported);
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2AHttpJson(agentBuilder, "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints when using string agent name.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2AHttpJson("agent", "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws ArgumentNullException for null endpoints when using string agent name.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2AJsonRpc("agent", "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentName.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentName_NullAgentName_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AHttpJson((string)null!, "/a2a"));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentException for empty agentName.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentName_EmptyAgentName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
app.MapA2AHttpJson(string.Empty, "/a2a"));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null path.
/// </summary>
[Fact]
public void MapA2AHttpJson_NullPath_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
app.MapA2AHttpJson(agentBuilder, null!));
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentException for whitespace-only path.
/// </summary>
[Fact]
public void MapA2AHttpJson_WhitespacePath_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
Assert.Throws<ArgumentException>(() =>
app.MapA2AHttpJson(agentBuilder, " "));
}
/// <summary>
/// Verifies that AddA2AServer throws ArgumentNullException for null services.
/// </summary>
[Fact]
public void AddA2AServer_NullServices_ThrowsArgumentNullException()
{
// Arrange
IServiceCollection services = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
services.AddA2AServer("agent"));
Assert.Equal("services", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer throws ArgumentNullException for null agentName.
/// </summary>
[Fact]
public void AddA2AServer_NullAgentName_ThrowsArgumentNullException()
{
// Arrange
IServiceCollection services = new ServiceCollection();
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
services.AddA2AServer((string)null!));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer throws ArgumentException for empty agentName.
/// </summary>
[Fact]
public void AddA2AServer_EmptyAgentName_ThrowsArgumentException()
{
// Arrange
IServiceCollection services = new ServiceCollection();
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
services.AddA2AServer(string.Empty));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer on IHostedAgentBuilder throws ArgumentNullException for null builder.
/// </summary>
[Fact]
public void AddA2AServer_NullAgentBuilder_ThrowsArgumentNullException()
{
// Arrange
IHostedAgentBuilder agentBuilder = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
agentBuilder.AddA2AServer());
Assert.Equal("agentBuilder", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null AIAgent.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAIAgent_NullAgent_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AHttpJson(agent, "/a2a"));
Assert.Equal("agent", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for AIAgent with null Name.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAIAgent_NullName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns((string?)null);
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AHttpJson(agentMock.Object, "/a2a"));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentException for AIAgent with whitespace Name.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAIAgent_WhitespaceName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns(" ");
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
app.MapA2AHttpJson(agentMock.Object, "/a2a"));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws ArgumentNullException for null AIAgent.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAIAgent_NullAgent_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AJsonRpc(agent, "/a2a"));
Assert.Equal("agent", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws ArgumentNullException for AIAgent with null Name.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAIAgent_NullName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns((string?)null);
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AJsonRpc(agentMock.Object, "/a2a"));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws ArgumentException for AIAgent with whitespace Name.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAIAgent_WhitespaceName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns(" ");
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
app.MapA2AJsonRpc(agentMock.Object, "/a2a"));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws InvalidOperationException when no A2AServer has been
/// registered for the specified agent via AddA2AServer.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithoutAddA2AServer_ThrowsInvalidOperationException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(() =>
app.MapA2AHttpJson("agent", "/a2a"));
Assert.Contains("agent", exception.Message);
Assert.Contains("AddA2AServer", exception.Message);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws InvalidOperationException when no A2AServer has been
/// registered for the specified agent via AddA2AServer.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithoutAddA2AServer_ThrowsInvalidOperationException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(() =>
app.MapA2AJsonRpc("agent", "/a2a"));
Assert.Contains("agent", exception.Message);
Assert.Contains("AddA2AServer", exception.Message);
}
}
@@ -0,0 +1,89 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
public sealed class A2AIntegrationTests
{
/// <summary>
/// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated.
/// </summary>
[Fact]
public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("test-agent", "Test instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication",
Version = "1.0"
};
// Map A2A with the agent card
app.MapA2A(agentBuilder, "/a2a/test-agent", agentCard);
await app.StartAsync();
try
{
// Get the test server client
TestServer testServer = app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
var httpClient = testServer.CreateClient();
// Act - Query the agent card endpoint
var requestUri = new Uri("/a2a/test-agent/v1/card", UriKind.Relative);
var response = await httpClient.GetAsync(requestUri);
// Assert
Assert.True(response.IsSuccessStatusCode, $"Expected successful response but got {response.StatusCode}");
var content = await response.Content.ReadAsStringAsync();
var jsonDoc = JsonDocument.Parse(content);
var root = jsonDoc.RootElement;
// Verify the card has expected properties
Assert.True(root.TryGetProperty("name", out var nameProperty));
Assert.Equal("Test Agent", nameProperty.GetString());
Assert.True(root.TryGetProperty("description", out var descProperty));
Assert.Equal("A test agent for A2A communication", descProperty.GetString());
// Verify the card has a URL property and it's not null/empty
Assert.True(root.TryGetProperty("url", out var urlProperty));
Assert.NotEqual(JsonValueKind.Null, urlProperty.ValueKind);
var url = urlProperty.GetString();
Assert.NotNull(url);
Assert.NotEmpty(url);
Assert.StartsWith("http", url, StringComparison.OrdinalIgnoreCase);
// agentCard's URL matches the agent endpoint
Assert.Equal($"{testServer.BaseAddress.ToString().TrimEnd('/')}/a2a/test-agent", url);
}
finally
{
await app.StopAsync();
}
}
}
@@ -1,459 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AServerServiceCollectionExtensions"/> class.
/// </summary>
public sealed class A2AServerServiceCollectionExtensionsTests
{
/// <summary>
/// Verifies that AddA2AServer with an agent name registers a keyed A2AServer
/// that can be resolved from the service provider.
/// </summary>
[Fact]
public async Task AddA2AServer_WithAgentName_ResolvesKeyedA2AServerAsync()
{
// Arrange
const string AgentName = "test-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
// Act
services.AddA2AServer(AgentName);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that AddA2AServer with an agent instance registers a keyed A2AServer
/// that can be resolved from the service provider using the agent's name.
/// </summary>
[Fact]
public async Task AddA2AServer_WithAgentInstance_ResolvesKeyedA2AServerAsync()
{
// Arrange
const string AgentName = "instance-agent";
var agentMock = CreateAgentMock(AgentName);
var services = new ServiceCollection();
// Act
services.AddA2AServer(agentMock.Object);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that when no ITaskStore or AgentSessionStore are registered,
/// AddA2AServer falls back to in-memory defaults and resolves successfully.
/// </summary>
[Fact]
public async Task AddA2AServer_WithNoCustomStores_FallsBackToInMemoryDefaultsAsync()
{
// Arrange
const string AgentName = "default-stores-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
// Act
services.AddA2AServer(AgentName);
// Assert - resolution succeeds without any stores registered
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that when a custom ITaskStore is registered, AddA2AServer uses it
/// instead of the default InMemoryTaskStore.
/// </summary>
[Fact]
public async Task AddA2AServer_WithCustomTaskStore_ResolvesSuccessfullyAsync()
{
// Arrange
const string AgentName = "custom-taskstore-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockTaskStore = new Mock<ITaskStore>();
services.AddKeyedSingleton(AgentName, mockTaskStore.Object);
// Act
services.AddA2AServer(AgentName);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that when a custom AgentSessionStore is registered, AddA2AServer uses it
/// instead of the default InMemoryAgentSessionStore.
/// </summary>
[Fact]
public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyAsync()
{
// Arrange
const string AgentName = "custom-sessionstore-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockSessionStore = new Mock<AgentSessionStore>();
services.AddKeyedSingleton(AgentName, mockSessionStore.Object);
// Act
services.AddA2AServer(AgentName);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that when a custom IAgentHandler is registered, AddA2AServer uses it
/// instead of creating a default A2AAgentHandler.
/// </summary>
[Fact]
public async Task AddA2AServer_WithCustomAgentHandler_ResolvesSuccessfullyAsync()
{
// Arrange
const string AgentName = "custom-handler-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockHandler = new Mock<IAgentHandler>();
services.AddKeyedSingleton(AgentName, mockHandler.Object);
// Act
services.AddA2AServer(AgentName);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that the configureOptions callback is invoked when provided.
/// </summary>
[Fact]
public async Task AddA2AServer_WithConfigureOptions_InvokesCallbackAsync()
{
// Arrange
const string AgentName = "options-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
bool callbackInvoked = false;
// Act
services.AddA2AServer(AgentName, options =>
{
callbackInvoked = true;
options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported;
});
// Assert - callback is invoked during resolution
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
Assert.True(callbackInvoked);
}
/// <summary>
/// Verifies that AddA2AServer with a null configureOptions does not throw.
/// </summary>
[Fact]
public async Task AddA2AServer_WithNullConfigureOptions_ResolvesSuccessfullyAsync()
{
// Arrange
const string AgentName = "null-options-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
// Act
services.AddA2AServer(AgentName, configureOptions: null);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that AddA2AServer throws when the agent name is null.
/// </summary>
[Fact]
public void AddA2AServer_WithNullAgentName_ThrowsArgumentException()
{
// Arrange
var services = new ServiceCollection();
// Act & Assert
Assert.ThrowsAny<ArgumentException>(() => services.AddA2AServer(agentName: null!));
}
/// <summary>
/// Verifies that AddA2AServer throws when the agent name is whitespace.
/// </summary>
[Fact]
public void AddA2AServer_WithWhitespaceAgentName_ThrowsArgumentException()
{
// Arrange
var services = new ServiceCollection();
// Act & Assert
Assert.ThrowsAny<ArgumentException>(() => services.AddA2AServer(agentName: " "));
}
/// <summary>
/// Verifies that AddA2AServer throws when the services parameter is null.
/// </summary>
[Fact]
public void AddA2AServer_WithNullServices_ThrowsArgumentNullException()
{
// Arrange
IServiceCollection services = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => services.AddA2AServer("agent"));
}
/// <summary>
/// Verifies that AddA2AServer with an agent instance throws when the agent is null.
/// </summary>
[Fact]
public void AddA2AServer_WithNullAgent_ThrowsArgumentNullException()
{
// Arrange
var services = new ServiceCollection();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => services.AddA2AServer(agent: null!));
}
/// <summary>
/// Verifies that AddA2AServer with an agent instance throws when the agent's Name is null.
/// </summary>
[Fact]
public void AddA2AServer_WithAgent_NullName_ThrowsArgumentNullException()
{
// Arrange
var services = new ServiceCollection();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns((string?)null);
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
services.AddA2AServer(agentMock.Object));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer with an agent instance throws when the agent's Name is whitespace.
/// </summary>
[Fact]
public void AddA2AServer_WithAgent_WhitespaceName_ThrowsArgumentException()
{
// Arrange
var services = new ServiceCollection();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns(" ");
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
services.AddA2AServer(agentMock.Object));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that when a custom <see cref="IAgentHandler"/> is registered as a keyed service,
/// the <see cref="A2AServer"/> uses it to process requests instead of the default handler.
/// </summary>
[Fact]
public async Task AddA2AServer_WithCustomHandler_CustomHandlerIsInvokedOnRequestAsync()
{
// Arrange
const string AgentName = "custom-handler-wiring";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockHandler = new Mock<IAgentHandler>();
mockHandler
.Setup(h => h.ExecuteAsync(
It.IsAny<RequestContext>(),
It.IsAny<AgentEventQueue>(),
It.IsAny<CancellationToken>()))
.Returns((RequestContext _, AgentEventQueue eq, CancellationToken ct) =>
eq.EnqueueMessageAsync(
new Message { MessageId = "resp", Role = Role.Agent, Parts = [new Part { Text = "Reply" }] }, ct).AsTask());
services.AddKeyedSingleton(AgentName, mockHandler.Object);
services.AddA2AServer(AgentName);
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
// Act
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
// Assert - the custom handler was invoked, not the default A2AAgentHandler
mockHandler.Verify(
h => h.ExecuteAsync(
It.IsAny<RequestContext>(),
It.IsAny<AgentEventQueue>(),
It.IsAny<CancellationToken>()),
Times.Once);
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
Assert.NotNull(response.Message);
}
/// <summary>
/// Verifies that when a custom <see cref="AgentSessionStore"/> is registered as a keyed service
/// and no custom <see cref="IAgentHandler"/> is registered, the default handler uses the custom
/// session store for session management during request processing.
/// </summary>
[Fact]
public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUsedOnRequestAsync()
{
// Arrange
const string AgentName = "custom-sessionstore-wiring";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockSessionStore = new Mock<AgentSessionStore>();
mockSessionStore
.Setup(x => x.GetSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new TestAgentSession());
mockSessionStore
.Setup(x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<AgentSession>(),
It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);
services.AddKeyedSingleton(AgentName, mockSessionStore.Object);
services.AddA2AServer(AgentName);
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
// Act
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
// Assert - the custom session store was used, not InMemoryAgentSessionStore
mockSessionStore.Verify(
x => x.GetSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()),
Times.Once);
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
Assert.NotNull(response.Message);
}
/// <summary>
/// Verifies that when no custom stores or handlers are registered, the server uses
/// the default in-memory stores and processes requests successfully end-to-end.
/// </summary>
[Fact]
public async Task AddA2AServer_WithNoCustomStores_DefaultStoresProcessRequestSuccessfullyAsync()
{
// Arrange
const string AgentName = "default-stores-request";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMockForRequests(AgentName).Object);
services.AddA2AServer(AgentName);
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
// Act
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
// Assert - request was processed successfully with default in-memory stores
Assert.NotNull(response);
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
Assert.NotNull(response.Message);
}
private static SendMessageRequest CreateTestSendMessageRequest() =>
new()
{
Message = new Message
{
MessageId = "test-id",
Role = Role.User,
Parts = [new Part { Text = "Hello" }]
}
};
private static Mock<AIAgent> CreateAgentMock(string name)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns(name);
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
return agentMock;
}
/// <summary>
/// Creates a mock <see cref="AIAgent"/> with session serialization support, suitable for
/// tests that exercise the full request processing path with <see cref="InMemoryAgentSessionStore"/>.
/// </summary>
private static Mock<AIAgent> CreateAgentMockForRequests(string name)
{
Mock<AIAgent> agentMock = CreateAgentMock(name);
agentMock
.Protected()
.Setup<ValueTask<JsonElement>>("SerializeSessionCoreAsync",
ItExpr.IsAny<AgentSession>(),
ItExpr.IsAny<JsonSerializerOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(JsonDocument.Parse("{}").RootElement);
return agentMock;
}
private sealed class TestAgentSession : AgentSession;
}
@@ -0,0 +1,866 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIAgentExtensions"/> class.
/// </summary>
public sealed class AIAgentExtensionsTests
{
/// <summary>
/// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have
/// AllowBackgroundResponses enabled and no AdditionalProperties.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
// Act
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
Metadata = null
});
// Assert
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
Assert.Null(capturedOptions.AdditionalProperties);
}
/// <summary>
/// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
// Act
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
Metadata = new Dictionary<string, JsonElement>
{
["key1"] = JsonSerializer.SerializeToElement("value1"),
["key2"] = JsonSerializer.SerializeToElement(42)
}
});
// Assert
Assert.NotNull(capturedOptions);
Assert.NotNull(capturedOptions.AdditionalProperties);
Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key1"));
Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key2"));
}
/// <summary>
/// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have
/// AllowBackgroundResponses enabled and no AdditionalProperties.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
// Act
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
Metadata = []
});
// Assert
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
Assert.Null(capturedOptions.AdditionalProperties);
}
/// <summary>
/// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync()
{
// Arrange
AdditionalPropertiesDictionary additionalProps = new()
{
["responseKey1"] = "responseValue1",
["responseKey2"] = 123
};
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = additionalProps
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(agentMessage.Metadata);
Assert.Equal(2, agentMessage.Metadata.Count);
Assert.True(agentMessage.Metadata.ContainsKey("responseKey1"));
Assert.True(agentMessage.Metadata.ContainsKey("responseKey2"));
Assert.Equal("responseValue1", agentMessage.Metadata["responseKey1"].GetString());
Assert.Equal(123, agentMessage.Metadata["responseKey2"].GetInt32());
}
/// <summary>
/// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = null
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Null(agentMessage.Metadata);
}
/// <summary>
/// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = []
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Null(agentMessage.Metadata);
}
/// <summary>
/// Verifies that when runMode is Message, the result is always an AgentMessage even when
/// the agent would otherwise support background responses.
/// </summary>
[Fact]
public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.DisallowBackground);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
/// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence.
/// </summary>
[Fact]
public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(capturedOptions);
Assert.True(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that a custom Dynamic delegate returning false produces an AgentMessage
/// even when the agent completes immediately (no ContinuationToken).
/// </summary>
[Fact]
public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWorkingStateAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, the returned task includes
/// intermediate messages from the initial response in its status message.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Status.Message);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(agentTask.Status.Message.Parts));
Assert.Equal("Starting work...", textPart.Text);
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, the continuation token
/// is serialized into the AgentTask.Metadata for persistence.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when a task is created (Working or Completed), the original user message
/// is added to the task history, matching the A2A SDK's behavior when it creates tasks internally.
/// </summary>
[Fact]
public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] };
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = originalMessage
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.History);
Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User);
}
/// <summary>
/// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
/// the returned AgentMessage preserves the original context ID.
/// </summary>
[Fact]
public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] };
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = originalMessage
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Equal("ctx-123", agentMessage.ContextId);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
/// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithSequentialResponses(
// First call: return response with ContinuationToken (long-running)
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): return completed response
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
ref callCount);
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — trigger OnMessageReceived to create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
// Act — invoke OnTaskUpdated to check on the background operation
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert — task should now be completed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Completed, updatedTask.Status.State);
Assert.NotNull(updatedTask.Artifacts);
Artifact artifact = Assert.Single(updatedTask.Artifacts);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("Done!", textPart.Text);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
/// and the agent returns another ContinuationToken, the task stays in Working state.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithSequentialResponses(
// First call: return response with ContinuationToken
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): still working, return another token
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
{
ContinuationToken = CreateTestContinuationToken()
},
ref callCount);
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — trigger OnMessageReceived to create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — invoke OnTaskUpdated; agent still working
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert — task should still be in Working state
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Working, updatedTask.Status.State);
}
/// <summary>
/// Verifies the full lifecycle: agent starts background work, first poll returns still working,
/// second poll returns completed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
return invocation switch
{
// First call: start background work
1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call: still working
2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Third call: done
_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")])
};
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
// Act — first poll: still working
AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
Assert.Equal(TaskState.Working, currentTask.Status.State);
// Act — second poll: completed
await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
Assert.Equal(TaskState.Completed, currentTask.Status.State);
// Assert — final output as artifact
Assert.NotNull(currentTask.Artifacts);
Artifact artifact = Assert.Single(currentTask.Artifacts);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("All done!", textPart.Text);
}
/// <summary>
/// Verifies that when the agent throws during a background operation poll,
/// the task is updated to Failed state.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
if (invocation == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
}
throw new InvalidOperationException("Agent failed");
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — poll the task; agent throws
await Assert.ThrowsAsync<InvalidOperationException>(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
// Assert — task should be Failed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Failed, updatedTask.Status.State);
}
/// <summary>
/// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state.
/// </summary>
[Fact]
public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Working on it...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken with no progress messages,
/// the task transitions to Working state with a null status message.
/// </summary>
[Fact]
public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMessageAsync()
{
// Arrange
AgentResponse response = new([])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
Assert.Null(agentTask.Status.Message);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message
/// and no continuation token in metadata, the task processes history and completes with a new artifact.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
return invocation switch
{
// First call: create a task with ContinuationToken
1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): complete the background operation
2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
// Third call (follow-up via OnTaskUpdated): complete follow-up
_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")])
};
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create a working task (with continuation token)
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — first OnTaskUpdated: completes the background operation
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!;
Assert.Equal(TaskState.Completed, agentTask.Status.State);
// Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated
agentTask.History ??= [];
agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] });
// Act — invoke OnTaskUpdated without a continuation token in metadata
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Completed, updatedTask.Status.State);
Assert.NotNull(updatedTask.Artifacts);
Assert.Equal(2, updatedTask.Artifacts.Count);
Artifact artifact = updatedTask.Artifacts[1];
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("Follow-up done!", textPart.Text);
}
/// <summary>
/// Verifies that when a task is cancelled, the continuation token is removed from metadata.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act — create a working task with a continuation token
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
// Act — cancel the task
await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None);
// Assert — continuation token should be removed from metadata
Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when the agent throws an OperationCanceledException during a poll,
/// it is re-thrown without marking the task as Failed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
if (invocation == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
}
throw new OperationCanceledException("Cancelled");
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — poll the task; agent throws OperationCanceledException
await Assert.ThrowsAsync<OperationCanceledException>(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
// Assert — task should still be Working, not Failed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Working, updatedTask.Status.State);
}
/// <summary>
/// Verifies that when the incoming message has a ContextId, it is used for the task
/// rather than generating a new one.
/// </summary>
[Fact]
public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage
{
MessageId = "test-id",
ContextId = "my-context-123",
Role = MessageRole.User,
Parts = [new TextPart { Text = "Hello" }]
}
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Equal("my-context-123", agentMessage.ContextId);
}
#pragma warning restore MEAI001
private static Mock<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
(_, _, options, _) => optionsCallback(options))
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
return agentMock;
}
private static Mock<AIAgent> CreateAgentMockWithResponse(AgentResponse response)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
return agentMock;
}
private static async Task<A2AResponse> InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams)
{
Func<MessageSendParams, CancellationToken, Task<A2AResponse>>? handler = taskManager.OnMessageReceived;
Assert.NotNull(handler);
return await handler.Invoke(messageSendParams, CancellationToken.None);
}
private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask)
{
Func<AgentTask, CancellationToken, Task>? handler = taskManager.OnTaskUpdated;
Assert.NotNull(handler);
await handler.Invoke(agentTask, CancellationToken.None);
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
private static ResponseContinuationToken CreateTestContinuationToken()
{
return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 });
}
#pragma warning restore MEAI001
private static Mock<AIAgent> CreateAgentMockWithSequentialResponses(
AgentResponse firstResponse,
AgentResponse secondResponse,
ref int callCount)
{
return CreateAgentMockWithCallCount(ref callCount, invocation =>
invocation == 1 ? firstResponse : secondResponse);
}
private static Mock<AIAgent> CreateAgentMockWithCallCount(
ref int callCount,
Func<int, AgentResponse> responseFactory)
{
// Use a StrongBox to allow the lambda to capture a mutable reference
StrongBox<int> callCountBox = new(callCount);
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
int currentCall = Interlocked.Increment(ref callCountBox.Value);
return responseFactory(currentCall);
});
return agentMock;
}
private sealed class TestAgentSession : AgentSession;
}
@@ -1,163 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AgentRunMode"/> class.
/// </summary>
public sealed class AgentRunModeTests
{
/// <summary>
/// Verifies that AllowBackgroundWhen throws ArgumentNullException for null delegate.
/// </summary>
[Fact]
public void AllowBackgroundWhen_NullDelegate_ThrowsArgumentNullException()
{
// Arrange & Act & Assert
Assert.Throws<ArgumentNullException>(() =>
AgentRunMode.AllowBackgroundWhen(null!));
}
/// <summary>
/// Verifies that DisallowBackground equals another DisallowBackground instance.
/// </summary>
[Fact]
public void Equals_DisallowBackground_AreEqual()
{
// Arrange
var mode1 = AgentRunMode.DisallowBackground;
var mode2 = AgentRunMode.DisallowBackground;
// Act & Assert
Assert.True(mode1.Equals(mode2));
Assert.True(mode1 == mode2);
Assert.False(mode1 != mode2);
Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode());
}
/// <summary>
/// Verifies that AllowBackgroundIfSupported equals another AllowBackgroundIfSupported instance.
/// </summary>
[Fact]
public void Equals_AllowBackgroundIfSupported_AreEqual()
{
// Arrange
var mode1 = AgentRunMode.AllowBackgroundIfSupported;
var mode2 = AgentRunMode.AllowBackgroundIfSupported;
// Act & Assert
Assert.True(mode1.Equals(mode2));
Assert.True(mode1 == mode2);
}
/// <summary>
/// Verifies that DisallowBackground and AllowBackgroundIfSupported are not equal.
/// </summary>
[Fact]
public void Equals_DifferentModes_AreNotEqual()
{
// Arrange
var disallow = AgentRunMode.DisallowBackground;
var allow = AgentRunMode.AllowBackgroundIfSupported;
// Act & Assert
Assert.False(disallow.Equals(allow));
Assert.False(disallow == allow);
Assert.True(disallow != allow);
}
/// <summary>
/// Verifies that Equals returns false for null.
/// </summary>
[Fact]
public void Equals_Null_ReturnsFalse()
{
// Arrange
var mode = AgentRunMode.DisallowBackground;
// Act & Assert
Assert.False(mode.Equals(null));
Assert.False(mode.Equals((object?)null));
Assert.False(mode == null);
Assert.True(mode != null);
}
/// <summary>
/// Verifies that two null AgentRunMode values are equal.
/// </summary>
[Fact]
public void Equals_BothNull_AreEqual()
{
// Arrange
AgentRunMode? mode1 = null;
AgentRunMode? mode2 = null;
// Act & Assert
Assert.True(mode1 == mode2);
Assert.False(mode1 != mode2);
}
/// <summary>
/// Verifies that ToString returns expected values.
/// </summary>
[Fact]
public void ToString_ReturnsExpectedValues()
{
// Act & Assert
Assert.Equal("message", AgentRunMode.DisallowBackground.ToString());
Assert.Equal("task", AgentRunMode.AllowBackgroundIfSupported.ToString());
Assert.Equal("dynamic", AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)).ToString());
}
/// <summary>
/// Verifies that Equals works correctly with object parameter.
/// </summary>
[Fact]
public void Equals_WithObjectParameter_WorksCorrectly()
{
// Arrange
var mode = AgentRunMode.DisallowBackground;
// Act & Assert
Assert.True(mode.Equals((object)AgentRunMode.DisallowBackground));
Assert.False(mode.Equals((object)AgentRunMode.AllowBackgroundIfSupported));
Assert.False(mode.Equals("not a run mode"));
}
/// <summary>
/// Verifies that two AllowBackgroundWhen instances with different delegates are not considered equal,
/// because equality includes delegate identity for dynamic modes.
/// </summary>
[Fact]
public void Equals_AllowBackgroundWhen_DifferentDelegates_AreNotEqual()
{
// Arrange
var mode1 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true));
var mode2 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false));
// Act & Assert
Assert.False(mode1.Equals(mode2));
Assert.True(mode1 != mode2);
}
/// <summary>
/// Verifies that two AllowBackgroundWhen instances with the same delegate are considered equal.
/// </summary>
[Fact]
public void Equals_AllowBackgroundWhen_SameDelegate_AreEqual()
{
// Arrange
static ValueTask<bool> CallbackAsync(A2ARunDecisionContext _, CancellationToken __) => ValueTask.FromResult(true);
var mode1 = AgentRunMode.AllowBackgroundWhen(CallbackAsync);
var mode2 = AgentRunMode.AllowBackgroundWhen(CallbackAsync);
// Act & Assert
Assert.True(mode1.Equals(mode2));
Assert.True(mode1 == mode2);
Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode());
}
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.Converters;
@@ -11,66 +10,66 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters;
public class MessageConverterTests
{
[Fact]
public void ToChatMessages_SendMessageRequest_Null_ReturnsEmptyCollection()
public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection()
{
SendMessageRequest? sendMessageRequest = null;
MessageSendParams? messageSendParams = null;
var result = sendMessageRequest!.ToChatMessages();
var result = messageSendParams!.ToChatMessages();
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToChatMessages_SendMessageRequest_WithNullMessage_ReturnsEmptyCollection()
public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection()
{
var sendMessageRequest = new SendMessageRequest
var messageSendParams = new MessageSendParams
{
Message = null!
};
var result = sendMessageRequest.ToChatMessages();
var result = messageSendParams.ToChatMessages();
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToChatMessages_SendMessageRequest_WithMessageWithoutParts_ReturnsEmptyCollection()
public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection()
{
var sendMessageRequest = new SendMessageRequest
var messageSendParams = new MessageSendParams
{
Message = new Message
Message = new AgentMessage
{
MessageId = "test-id",
Role = Role.User,
Role = MessageRole.User,
Parts = null!
}
};
var result = sendMessageRequest.ToChatMessages();
var result = messageSendParams.ToChatMessages();
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToChatMessages_SendMessageRequest_WithValidTextMessage_ReturnsCorrectChatMessage()
public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage()
{
var sendMessageRequest = new SendMessageRequest
var messageSendParams = new MessageSendParams
{
Message = new Message
Message = new AgentMessage
{
MessageId = "test-id",
Role = Role.User,
Role = MessageRole.User,
Parts =
[
new Part { Text = "Hello, world!" }
new TextPart { Text = "Hello, world!" }
]
}
};
var result = sendMessageRequest.ToChatMessages();
var result = messageSendParams.ToChatMessages();
Assert.NotNull(result);
Assert.Single(result);
@@ -83,131 +82,4 @@ public class MessageConverterTests
var textContent = Assert.IsType<TextContent>(chatMessage.Contents.First());
Assert.Equal("Hello, world!", textContent.Text);
}
[Fact]
public void ToParts_NullList_ReturnsEmptyList()
{
// Arrange
IList<ChatMessage>? messages = null;
// Act
var result = messages!.ToParts();
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToParts_EmptyList_ReturnsEmptyList()
{
// Arrange
IList<ChatMessage> messages = [];
// Act
var result = messages.ToParts();
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToParts_WithTextContent_ReturnsTextPart()
{
// Arrange
IList<ChatMessage> messages =
[
new ChatMessage(ChatRole.Assistant, "Hello from the agent!")
];
// Act
var result = messages.ToParts();
// Assert
Assert.Single(result);
Assert.Equal("Hello from the agent!", result[0].Text);
}
[Fact]
public void ToParts_WithMultipleMessages_ReturnsAllParts()
{
// Arrange
IList<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "First message"),
new ChatMessage(ChatRole.Assistant, "Second message")
];
// Act
var result = messages.ToParts();
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("First message", result[0].Text);
Assert.Equal("Second message", result[1].Text);
}
[Fact]
public void ToParts_AgentResponseUpdate_WithNoContents_ReturnsEmptyList()
{
// Arrange
var update = new AgentResponseUpdate();
// Act
var result = update.ToParts();
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToParts_AgentResponseUpdate_WithTextContent_ReturnsTextPart()
{
// Arrange
var update = new AgentResponseUpdate(ChatRole.Assistant, "Hello from streaming!");
// Act
var result = update.ToParts();
// Assert
Assert.Single(result);
Assert.Equal("Hello from streaming!", result[0].Text);
}
[Fact]
public void ToParts_AgentResponseUpdate_WithMultipleContents_ReturnsAllParts()
{
// Arrange
var update = new AgentResponseUpdate(ChatRole.Assistant, [
new TextContent("First chunk"),
new TextContent("Second chunk")
]);
// Act
var result = update.ToParts();
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("First chunk", result[0].Text);
Assert.Equal("Second chunk", result[1].Text);
}
[Fact]
public void ToParts_AgentResponseUpdate_WithUnsupportedContent_FiltersOutNulls()
{
// Arrange - FunctionCallContent maps to null Part since it's not a supported A2A content type
var update = new AgentResponseUpdate(ChatRole.Assistant, [
new TextContent("Supported text"),
new FunctionCallContent("call-1", "myFunction")
]);
// Act
var result = update.ToParts();
// Assert - only the text part should be returned
Assert.Single(result);
Assert.Equal("Supported text", result[0].Text);
}
}
@@ -0,0 +1,479 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Tests for MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions.MapA2A method.
/// </summary>
public sealed class EndpointRouteA2ABuilderExtensionsTests
{
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2A(agentBuilder, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null agentBuilder.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
IHostedAgentBuilder agentBuilder = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2A(agentBuilder, "/a2a"));
Assert.Equal("agentBuilder", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a");
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a", taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_WithAgentCard_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a", agentCard, taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2A("agent", "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with string agent name correctly maps the agent.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2A("agent", "/a2a");
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with string agent name and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2A("agent", "/a2a", taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with string agent name and agent card succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_WithAgentCard_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A("agent", "/a2a", agentCard);
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A("agent", "/a2a", agentCard, taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2A((AIAgent)null!, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with AIAgent correctly maps the agent.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a");
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a", taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with AIAgent and agent card succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_WithAgentCard_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a", agentCard);
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a", agentCard, taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using ITaskManager.
/// </summary>
[Fact]
public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
ITaskManager taskManager = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2A(taskManager, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that multiple agents can be mapped to different paths.
/// </summary>
[Fact]
public void MapA2A_MultipleAgents_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapA2A(agent1Builder, "/a2a/agent1");
app.MapA2A(agent2Builder, "/a2a/agent2");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that custom paths can be specified for A2A endpoints.
/// </summary>
[Fact]
public void MapA2A_WithCustomPath_AcceptsValidPath()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapA2A(agentBuilder, "/custom/a2a/path");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that task manager configuration callback is invoked correctly.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_TaskManagerConfigurationCallbackInvoked()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
bool configureCallbackInvoked = false;
// Act
app.MapA2A(agentBuilder, "/a2a", taskManager =>
{
configureCallbackInvoked = true;
Assert.NotNull(taskManager);
});
// Assert
Assert.True(configureCallbackInvoked);
}
/// <summary>
/// Verifies that agent card with all properties is accepted.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A comprehensive test agent"
};
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
Assert.NotNull(result);
}
}
@@ -1,187 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal enum ChatRoleType
{
None,
User,
Assistant,
Custom
}
internal static class ChatRoleTestingExtensions
{
public const string CustomChatRoleName = nameof(CustomChatRole);
public static ChatRole CustomChatRole { get; } = new(CustomChatRoleName);
public static ChatRole? ToChatRole(this ChatRoleType type)
=> type switch
{
ChatRoleType.None => null,
ChatRoleType.User => ChatRole.User,
ChatRoleType.Assistant => ChatRole.Assistant,
ChatRoleType.Custom => CustomChatRole,
_ => throw new ArgumentOutOfRangeException(
nameof(type),
type,
$"Invalid ChatRoleType {type}; expecting one of {string.Join(",",
[null,
ChatRole.User,
ChatRole.Assistant,
CustomChatRole])}")
};
}
public class ChatForwardingExecutorTests
{
private async Task<TestWorkflowContext> RunForwardMessageTestAsync<TMessage>(ChatForwardingExecutor executor, TMessage message)
where TMessage : notnull
{
// Ensure that we have constructed the Protocol (and registered the handlers)
_ = executor.Protocol;
TestWorkflowContext testContext = new(executor.Id);
object? callResult = await executor.ExecuteCoreAsync(message, new TypeId(typeof(TMessage)), testContext);
callResult.Should().BeNull(); // ChatForwardingExecutor's do not have a return type
return testContext;
}
private const string TestMessageContent = nameof(TestMessageContent);
[Fact]
public async Task Test_ChatForwardingExecutor_DoesNotForwardStringByDefaultAsync()
{
ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor));
// Act
Func<Task<TestWorkflowContext>> action = () => this.RunForwardMessageTestAsync(executor, TestMessageContent);
await action.Should().ThrowAsync<NotSupportedException>();
}
[Theory]
[InlineData(ChatRoleType.None)]
[InlineData(ChatRoleType.User)]
[InlineData(ChatRoleType.Assistant)]
[InlineData(ChatRoleType.Custom)]
internal async Task Test_ChatForwardingExecutor_ForwardsStringIfConfiguredAsync(ChatRoleType chatRoleType)
{
// Arrange
ChatForwardingExecutorOptions options = new()
{
StringMessageChatRole = chatRoleType.ToChatRole()
};
ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor), options);
// Act
Func<Task<TestWorkflowContext>> action = () => this.RunForwardMessageTestAsync(executor, TestMessageContent);
// Assert
if (options.StringMessageChatRole is ChatRole chatRole)
{
TestWorkflowContext testContext = await action();
testContext.SentMessages.Should().HaveCount(1)
.And.BeEquivalentTo([new ChatMessage(chatRole, TestMessageContent)]);
}
else
{
await action.Should().ThrowAsync<NotSupportedException>();
}
}
[Fact]
public async Task Test_ChatForwardingExecutor_ForwardsChatMessageUnmodifiedAsync()
{
// Arrange
ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor));
ChatMessage testMessage = new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent);
// Act
TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testMessage);
// Assert
testContext.SentMessages.Should().ContainSingle(message => ReferenceEquals(message, testMessage));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Test_ChatForwardingExecutor_ForwardsChatMessageListUnmodifiedAsync(bool sendAsIEnumerable)
{
// Arrange
ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor));
List<ChatMessage> testMessages = [new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent),
new(ChatRole.Assistant, "ResponseMessage")];
// Act
TestWorkflowContext testContext
= sendAsIEnumerable
? await this.RunForwardMessageTestAsync<IEnumerable<ChatMessage>>(executor, testMessages)
: await this.RunForwardMessageTestAsync(executor, testMessages);
// Assert
testContext.SentMessages.Should().ContainSingle(messages => ReferenceEquals(messages, testMessages));
}
[Fact]
public async Task Test_ChatForwardingExecutor_ForwardsChatMessageArrayUnchangedAsync()
{
// Arrange
ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor));
ChatMessage[] testMessages = [new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent),
new(ChatRole.Assistant, "ResponseMessage")];
// Act
TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testMessages);
// Assert
testContext.SentMessages.Should().ContainSingle(messages => ReferenceEquals(messages, testMessages));
}
[Fact]
public async Task Test_ChatForwardingExecutor_ForwardsMessageCollectionAsListAsync()
{
// Arrange
ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor));
ConcurrentBag<ChatMessage> testMessages = [new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent),
new(ChatRole.Assistant, "ResponseMessage")];
// Act
TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testMessages);
// Assert
testContext.SentMessages.Should().ContainSingle(messages => !ReferenceEquals(messages, testMessages))
.And.Subject.Single().Should().BeEquivalentTo(testMessages);
}
[Theory]
[InlineData(null)]
[InlineData(false)]
[InlineData(true)]
public async Task Test_ChatForwardingExecutor_ForwardsTurnTokenUnmodifiedAsync(bool? emitEvents)
{
// Arrange
ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor));
TurnToken testTurnToken = new(emitEvents);
// Act
TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testTurnToken);
// Assert
testContext.SentMessages.Should().BeEquivalentTo([testTurnToken]);
}
}
@@ -1,422 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class ExecutorTestsBase
{
public sealed record TextMessage(string Text);
public const string TestMessageContent = nameof(TestMessage);
public static TextMessage TestMessage { get; } = new(TestMessageContent);
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "Test Object")]
public sealed record DataMessage(string Base64Bytes)
{
private static string ToBase64String(string text, Encoding? expectedEncoding)
{
byte[] bytes = (expectedEncoding ?? Encoding.UTF8).GetBytes(text);
return Convert.ToBase64String(bytes);
}
public DataMessage(TextMessage textMessage, Encoding? expectedEncoding = null) : this(ToBase64String(textMessage.Text, expectedEncoding))
{ }
}
public const string DataMessageContent = nameof(DataMessage);
public static DataMessage TestDataMessage { get; } = new(TestMessage);
public sealed class InvocationEvent<TMessage>(TMessage message) : WorkflowEvent(message)
{
public TMessage Message => message;
}
internal sealed record ExecutorTestResult(TestWorkflowContext Context, object? CallResult);
internal async ValueTask<ExecutorTestResult> Run_FunctionExecutor_MessageHandlerTestAsync<TMessage>(Executor executor, TMessage message, CancellationToken cancellationToken = default)
where TMessage : notnull
{
TestWorkflowContext workflowContext = this.CreateWorkflowContext(executor);
_ = executor.DescribeProtocol();
object? result = await executor.ExecuteCoreAsync(message, new(typeof(TMessage)), workflowContext, cancellationToken);
return new(workflowContext, result);
}
internal static void CheckInvoked<TMessage>(ExecutorTestResult result, TMessage expectedInput, object? expectedCallResult = null)
where TMessage : class
{
result.CallResult.Should().Be(expectedCallResult);
result.Context.EmittedEvents.Should().Contain(evt => evt is ExecutorInvokedEvent
&& ((ExecutorInvokedEvent)evt).Data as TMessage == expectedInput)
.And.Contain(evt => evt is ExecutorCompletedEvent
&& ((ExecutorCompletedEvent)evt).Data == expectedCallResult);
}
internal static void CheckInvoked<TMessage, TOutput>(ExecutorTestResult result, TMessage expectedInput, TOutput expectedCallResult)
where TMessage : class
where TOutput : class
{
result.CallResult.Should().Be(expectedCallResult);
result.Context.EmittedEvents.Should().Contain(evt => evt is ExecutorInvokedEvent
&& ((ExecutorInvokedEvent)evt).Data as TMessage == expectedInput)
.And.Contain(evt => evt is ExecutorCompletedEvent
&& ((ExecutorCompletedEvent)evt).Data as TOutput == expectedCallResult);
}
internal TestWorkflowContext CreateWorkflowContext(Executor executor) => new(executor.Id);
}
public class FunctionExecutorTests : ExecutorTestsBase
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Test_FunctionExecutor__1_InvokesDelegateSuccessfullyAsync(bool useAsync)
{
// Arrange
FunctionExecutor<TextMessage> executor = useAsync
? new(nameof(FunctionExecutor<>), MessageHandlerAsync)
: new(nameof(FunctionExecutor<>), MessageHandler);
// Act
ExecutorTestResult result = await this.Run_FunctionExecutor_MessageHandlerTestAsync(executor, TestMessage);
// Assert
CheckInvoked(result, TestMessage);
// Helpers
ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> default;
void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) { }
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Test_FunctionExecutor__2_InvokesDelegateSuccessfullyAsync(bool useAsync)
{
// Arrange
FunctionExecutor<TextMessage, DataMessage> executor = useAsync
? new(nameof(FunctionExecutor<,>), MessageHandlerAsync)
: new(nameof(FunctionExecutor<,>), MessageHandler);
// Act
ExecutorTestResult result = await this.Run_FunctionExecutor_MessageHandlerTestAsync(executor, TestMessage);
// Assert
CheckInvoked(result, TestMessage, TestDataMessage);
// Helpers
ValueTask<DataMessage> MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> new(new DataMessage(message));
DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> new(message);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void Test_FunctionExecutor__1_SendTypesAreRegistered(bool useAsync, bool useAnnotated)
{
// Arrange
IEnumerable<Type>? sendTypes = useAnnotated
? null
: [typeof(TextMessage)];
FunctionExecutor<TextMessage> executor = useAsync
? new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotatedAsync
: MessageHandlerAsync, sentMessageTypes: sendTypes)
: new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotated
: MessageHandler, sentMessageTypes: sendTypes);
// Act
ProtocolDescriptor protocol = executor.DescribeProtocol();
// Assert
protocol.Sends.Should().BeEquivalentTo([typeof(TextMessage)]);
protocol.Yields.Should().BeEmpty();
// Helpers
[SendsMessage(typeof(TextMessage))]
ValueTask MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> MessageHandlerAsync(message, context, cancellationToken);
ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> context.SendMessageAsync(message, cancellationToken);
[SendsMessage(typeof(TextMessage))]
void MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> MessageHandler(message, context, cancellationToken);
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> context.SendMessageAsync(message, cancellationToken).AsTask().GetAwaiter().GetResult();
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void Test_FunctionExecutor__2_SendTypesAreRegistered(bool useAsync, bool useAnnotated)
{
// Arrange
ExecutorOptions options = new()
{
AutoSendMessageHandlerResultObject = false,
AutoYieldOutputHandlerResultObject = false
};
IEnumerable<Type>? sendTypes = useAnnotated
? null
: [typeof(TextMessage)];
FunctionExecutor<TextMessage, DataMessage> executor
= useAsync
? new(nameof(FunctionExecutor<,>), useAnnotated ? MessageHandlerAnnotatedAsync
: MessageHandlerAsync, options, sentMessageTypes: sendTypes)
: new(nameof(FunctionExecutor<,>), useAnnotated ? MessageHandlerAnnotated
: MessageHandler, options, sentMessageTypes: sendTypes);
// Act
ProtocolDescriptor protocol = executor.DescribeProtocol();
// Assert
protocol.Sends.Should().BeEquivalentTo([typeof(TextMessage)]);
protocol.Yields.Should().BeEmpty();
// Helpers
[SendsMessage(typeof(TextMessage))]
ValueTask<DataMessage> MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> MessageHandlerAsync(message, context, cancellationToken);
async ValueTask<DataMessage> MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
{
await context.SendMessageAsync(message, cancellationToken);
return new(message);
}
[SendsMessage(typeof(TextMessage))]
DataMessage MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> MessageHandler(message, context, cancellationToken);
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
{
context.SendMessageAsync(message, cancellationToken).AsTask().GetAwaiter().GetResult();
return new(message);
}
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void Test_FunctionExecutor__1_YieldTypesAreRegistered(bool useAsync, bool useAnnotated)
{
// Arrange
IEnumerable<Type>? yieldTypes = useAnnotated
? null
: [typeof(DataMessage)];
FunctionExecutor<TextMessage> executor = useAsync
? new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotatedAsync
: MessageHandlerAsync, outputTypes: yieldTypes)
: new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotated
: MessageHandler, outputTypes: yieldTypes);
// Act
ProtocolDescriptor protocol = executor.DescribeProtocol();
// Assert
protocol.Yields.Should().BeEquivalentTo([typeof(DataMessage)]);
protocol.Sends.Should().BeEmpty();
// Helpers
[YieldsOutput(typeof(DataMessage))]
ValueTask MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> MessageHandlerAsync(message, context, cancellationToken);
ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> context.YieldOutputAsync(new DataMessage(message), cancellationToken);
[YieldsOutput(typeof(DataMessage))]
void MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> MessageHandler(message, context, cancellationToken);
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> context.YieldOutputAsync(new DataMessage(message), cancellationToken).AsTask().GetAwaiter().GetResult();
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void Test_FunctionExecutor__2_YieldTypesAreRegistered(bool useAsync, bool useAnnotated)
{
// Arrange
ExecutorOptions options = new()
{
AutoSendMessageHandlerResultObject = false,
AutoYieldOutputHandlerResultObject = false
};
IEnumerable<Type>? yieldTypes = useAnnotated
? null
: [typeof(DataMessage)];
FunctionExecutor<TextMessage, DataMessage> executor
= useAsync
? new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotatedAsync
: MessageHandlerAsync, options, outputTypes: yieldTypes)
: new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotated
: MessageHandler, options, outputTypes: yieldTypes);
// Act
ProtocolDescriptor protocol = executor.DescribeProtocol();
// Assert
protocol.Yields.Should().BeEquivalentTo([typeof(DataMessage)]);
protocol.Sends.Should().BeEmpty();
// Helpers
[YieldsOutput(typeof(DataMessage))]
ValueTask<DataMessage> MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> MessageHandlerAsync(message, context, cancellationToken);
async ValueTask<DataMessage> MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
{
await context.YieldOutputAsync(new DataMessage(message), cancellationToken);
return new(message);
}
[YieldsOutput(typeof(DataMessage))]
DataMessage MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> MessageHandler(message, context, cancellationToken);
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
{
context.YieldOutputAsync(new DataMessage(message), cancellationToken).AsTask().GetAwaiter().GetResult();
return new(message);
}
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
}
[Theory]
[InlineData(false, false, false)]
[InlineData(false, false, true)]
[InlineData(false, true, false)]
[InlineData(false, true, true)]
[InlineData(true, false, false)]
[InlineData(true, false, true)]
[InlineData(true, true, false)]
[InlineData(true, true, true)]
public void Test_FunctionExecutor__1_ExecutorOptionsAreNoOp(bool useAsync, bool autoSendReturnValue, bool autoYieldReturnValue)
{
// Because FunctionExecutor<TInput> does not have a rail for a returned value, setting up options for it will
// not register any output types
ExecutorOptions options = new()
{
AutoSendMessageHandlerResultObject = autoSendReturnValue,
AutoYieldOutputHandlerResultObject = autoYieldReturnValue
};
FunctionExecutor<TextMessage> executor = useAsync
? new(nameof(FunctionExecutor<>), MessageHandlerAsync, options)
: new(nameof(FunctionExecutor<>), MessageHandler, options);
ProtocolDescriptor protocol = executor.DescribeProtocol();
protocol.Sends.Should().BeEmpty();
protocol.Yields.Should().BeEmpty();
// Helpers
ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> context.SendMessageAsync(message, cancellationToken);
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> context.SendMessageAsync(message, cancellationToken).AsTask().GetAwaiter().GetResult();
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
}
[Theory]
[InlineData(false, false, false)]
[InlineData(false, false, true)]
[InlineData(false, true, false)]
[InlineData(false, true, true)]
[InlineData(true, false, false)]
[InlineData(true, false, true)]
[InlineData(true, true, false)]
[InlineData(true, true, true)]
public async Task Test_FunctionExecutor__2_ExecutorOptionsCauseCorrectRegistration_AndAutoBehaviorAsync(bool useAsync, bool autoSendReturnValue, bool autoYieldReturnValue)
{
// Arrange
// Because FunctionExecutor<TInput> does not have a rail for a returned value, setting up options for it will
// not register any output types
ExecutorOptions options = new()
{
AutoSendMessageHandlerResultObject = autoSendReturnValue,
AutoYieldOutputHandlerResultObject = autoYieldReturnValue
};
FunctionExecutor<TextMessage, DataMessage> executor = useAsync
? new(nameof(FunctionExecutor<>), MessageHandlerAsync, options)
: new(nameof(FunctionExecutor<>), MessageHandler, options);
// Act
ExecutorTestResult result = await this.Run_FunctionExecutor_MessageHandlerTestAsync(executor, TestMessage);
ProtocolDescriptor protocol = executor.DescribeProtocol();
// Assert
CheckInvoked(result, TestMessage, TestDataMessage);
if (autoSendReturnValue)
{
protocol.Sends.Should().BeEquivalentTo([typeof(DataMessage)]);
result.Context.SentMessages.Should().ContainEquivalentOf(TestDataMessage);
}
else
{
protocol.Sends.Should().BeEmpty();
result.Context.SentMessages.Should().NotContainEquivalentOf(TestDataMessage);
}
if (autoYieldReturnValue)
{
protocol.Yields.Should().BeEquivalentTo([typeof(DataMessage)]);
result.Context.YieldedOutputs.Should().ContainEquivalentOf(TestDataMessage);
}
else
{
protocol.Yields.Should().BeEmpty();
result.Context.YieldedOutputs.Should().NotContainEquivalentOf(TestDataMessage);
}
// Helpers
ValueTask<DataMessage> MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> new(new DataMessage(message));
DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> new(message);
}
}
@@ -269,29 +269,6 @@ public class SampleSmokeTest
_ = await Step9EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
}
/// <summary>
/// Stress regression for the off-thread run-status race: after
/// <c>Run.ResumeAsync</c> returns at a halt boundary,
/// callers must observe a stable terminal status and never a transient
/// <see cref="RunStatus.Running"/>. Step9 is the canonical multi-response resume
/// sample; prior to the fix in <see cref="Execution.StreamingRunEventStream"/>,
/// its `runStatus.Should().Be(RunStatus.Idle)` assertion failed intermittently
/// on roughly 1-in-10 iterations under InProcess_OffThread.
/// </summary>
[Fact]
internal async Task Test_RunSample_Step9_OffThread_MultiResponseResume_StatusIsStableAsync()
{
const int Iterations = 50;
for (int i = 0; i < Iterations; i++)
{
using StringWriter writer = new();
_ = await Step9EntryPoint.RunAsync(
writer,
ExecutionEnvironment.InProcess_OffThread.ToWorkflowExecutionEnvironment());
}
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
@@ -206,14 +206,6 @@ internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor
}
}
public class NonChatProtocolExecutor() : Executor<string>(nameof(NonChatProtocolExecutor))
{
public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return default;
}
}
public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
{
private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent
@@ -740,25 +732,6 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
.BeEmpty();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Test_AsAgent_FailsWhenNotChatProtocolAsync(bool runAsync)
{
// Arrange
NonChatProtocolExecutor executor = new();
executor.DescribeProtocol().IsChatProtocol().Should().BeFalse();
Workflow workflow = new WorkflowBuilder(executor).Build();
AIAgent workflowAsAgent = workflow.AsAIAgent();
Func<Task> action = runAsync
? () => workflowAsAgent.RunStreamingAsync().ToAgentResponseAsync()
: () => workflowAsAgent.RunAsync();
await action.Should().ThrowAsync<InvalidOperationException>();
}
private async Task Run_AsAgent_OutgoingMessagesInHistoryAsync(Workflow workflow, bool runAsync)
{
// Arrange

Some files were not shown because too many files have changed in this diff Show More