Compare commits

...
Author SHA1 Message Date
Jacob Alber 8a1ad0199a fix: whitespace formatting 2026-05-07 16:44:17 +00:00
Jacob Alber cbc471dc18 fix sample project 2026-05-07 16:41:41 +00:00
Jacob Alber 6ddb74f8b3 fix: Update for breaking changes in Github.Copilot.SDK 2026-05-07 16:25:15 +00:00
2f93971a59 Fix formatting
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 12:05:32 -04:00
Jacob Alber 93ccf52c39 feat: Update Github Copilot SDK to 1.0.0-beta.2 2026-05-07 11:53:19 -04:00
44381c051b .NET: Support reasoning events in AGUI (#4953)
* Support reasoning

* MEAI gives the same MessageId for reasoning and text content because they are part of the same logical model response. Create a new GUID for reasoning messages to be consistent with AGUI protocol and establish no link between reasoning and text messages

* When a frontend AG-UI client sends conversation history back in a subsequent POST, any accumulated role: "reasoning" messages fail deserialization in AGUIMessageJsonConverter because the role wasn't handled - causing the request to fail.

This adds AGUIReasoningMessage with Content and EncryptedValue properties, registers it in the JSON converter and serializer context, and converts it to TextReasoningContent (with ProtectedData) in AsChatMessages.

* Added MapReasoningMessage - converts a ChatMessage containing TextReasoningContent to AGUIReasoningMessage for c# client

* review

* Support reasoning

* MEAI gives the same MessageId for reasoning and text content because they are part of the same logical model response. Create a new GUID for reasoning messages to be consistent with AGUI protocol and establish no link between reasoning and text messages

* When a frontend AG-UI client sends conversation history back in a subsequent POST, any accumulated role: "reasoning" messages fail deserialization in AGUIMessageJsonConverter because the role wasn't handled - causing the request to fail.

This adds AGUIReasoningMessage with Content and EncryptedValue properties, registers it in the JSON converter and serializer context, and converts it to TextReasoningContent (with ProtectedData) in AsChatMessages.

* Added MapReasoningMessage - converts a ChatMessage containing TextReasoningContent to AGUIReasoningMessage for c# client

* review

* dotnet format

* Replace hardcoded string with constant

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 15:09:04 +00:00
Tao ChenandGitHub 213491da66 Python: Add support for function approval flow in Foundry hosted agent (#5666)
* Add support for function approval flow in Foundry hosted agent

* Address comments

* Address comments

* Address comments
2026-05-07 14:55:26 +00:00
a95493a909 Python: Core: notify agent of external AgentModeProvider mode changes (#5650)
When the operating mode is changed externally (e.g. via a slash-command handler
calling set_agent_mode), the agent's chat history still shows the prior set_mode
tool call near the end. Updating only the system instructions is insufficient —
models tend to anchor on the recent tool call and ignore the new mode.

Mirror the .NET AgentModeProvider behavior: when set_agent_mode detects an actual
mode change, record the previous mode in provider state. On the next before_run,
the provider pops that flag and injects a user-role notification message
announcing the switch, so the most recent context unambiguously reflects the
current mode. The agent-driven set_mode tool path bypasses this so it does not
trigger a redundant notification on its own change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 02:58:38 +00:00
cdd80c61ac .NET: Issue 5662 (#5668)
* Fix dangling function_call on approval response in Foundry hosting (#5662)

Make the wire<->AF approval translation in Microsoft.Agents.AI.Foundry.Hosting lossless so the resume turn pairs function_call/function_call_output correctly.

Root cause: InputConverter.ConvertMcpApprovalResponse rebuilt FunctionCallContent with CallId set to the FICC-composed AF request id (ficc_<callId>) and Name hardcoded to 'mcp_approval'. This (a) broke Azure Conversations pairing because the persisted function_call had CallId <callId> without prefix, and (b) made FICC unable to invoke the original tool by name on resume.

Fix: ToolApprovalIdMap now records the original FunctionCallContent (CallId, Name, Arguments) keyed by wire id at outbound time. InputConverter reconstructs the original FCC on inbound, falling back to the legacy placeholder when no mapping exists.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Suppress orphan function_call items at the wire (#5662)

Foundry-Hosting's OutputConverter was emitting FunctionCallContent as wire `function_call` items while dropping the paired FunctionResultContent. The result: every auto-invoked tool call left an orphan `function_call` in the response store. The next turn (chained via previous_response_id or via a workflow that yields after one turn under externalLoop) reloaded that history and submitted it to Azure Conversations, which rejected it with HTTP 400 `No tool output found for function call ...`.

Function call/result pairs are entirely internal to the agent's tool-calling loop and have no place on the wire. Approval-required calls already surface separately via ToolApprovalRequestContent → mcp_approval_request, so dropping FCC is safe.

FCC's message-close behavior is preserved so pre-tool text doesn't accidentally concatenate with post-tool text under the same MessageId. Existing OutputConverter tests asserting FCC wire emission are updated to assert suppression.

Verified end-to-end against the declarative-workflow-menu external_loop bench: three-turn previous_response_id chain (menu → carbonara price → EXIT) now completes, where it previously failed at turn 2 with HTTP 400.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fail fast when no approval mapping is recorded (#5662)

The previous best-effort placeholder fallback in InputConverter.ConvertMcpApprovalResponse couldn't actually round-trip — it just delayed and obscured the failure as an HTTP 400 deep inside the agent loop. Throw InvalidOperationException with the wire id and a clear cause hint instead so the failure is local and actionable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Trim narrative comments and exception message (#5662)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Defer FunctionCallContent emission until matched FunctionResultContent (#5662)

Replace blanket FCC suppression with deferred emission. FunctionCallContent
is buffered (name + serialized arguments) keyed by CallId; the function_call
and function_call_output wire items are only flushed once the matching
FunctionResultContent arrives.

- Auto-invoked FCC/FRC pairs surface as paired wire items so Azure's stored
  conversation has matched call+output and previous_response_id resume
  works (closes the orphan-function_call symptom from #5662).
- Orphan FCCs (e.g. workflow paused at a checkpoint mid-tool-loop) are
  dropped so they never poison the response store.
- Approval flows are unchanged: TARC still emits mcp_approval_request and
  the post-approval FRC has no buffered FCC to pair with so it is dropped;
  the approval round-trip handles its own pairing via mcp_approval_*.
- Leaves the door open for future client-side function calling: that
  pattern would surface an FCC without an FRC, would need to opt out of
  buffering, but the wire shape is already correct.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Emit FunctionCallContent and FunctionResultContent directly (option B)

Replace the deferred-emission/buffer-and-drop strategy with direct emission of both function_call and function_call_output wire items.

Rationale: a lone FunctionCallContent in OutputConverter's input can mean two semantically different things, and only the caller knows which:

- Auto-invoke (FICC response surface): always paired with a matching FRC; both halves should appear on the wire as historical record.

- HITL / port-pause request (typed RequestPort<FunctionCallContent,...> or workflow synthesizing a request): a lone FCC IS the wire signal that the caller must resume by supplying a function_call_output.

Buffering+dropping orphans silently swallows the second case. Emitting both directly is the only correct shape for OpenAI Responses semantics.

The InputConverter already accepts function_call_output and mcp_approval_response on resume, so the round-trip works for both kinds.

The approval-flow round-trip fixes (ToolApprovalIdMap rich ApprovalEntry, fail-fast on missing mapping in ConvertMcpApprovalResponse) remain intact.

Tests: updated 7 OutputConverter tests + 1 OutputConverterWorkflow test that asserted the old buffer/drop semantics; all 227 tests pass.

Refs #5662

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR #5668 review feedback on TryLoadMap

Stop swallowing JsonException in ToolApprovalIdMap.TryLoadMap. The catch block recovered to an empty map and a stale comment claimed the caller would gracefully degrade via a 'wire-id fallback path' — but that path no longer exists: InputConverter.ConvertMcpApprovalResponse fails fast when no entry is found.

Letting the JsonException propagate produces an error message that points at the actual cause (a state-bag format incompatibility), instead of converting it into a confusing 'no approval mapping recorded' InvalidOperationException one stack frame later.

Refs #5662, PR #5668

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR #5668 review feedback round 2

- OutputConverter FRC: emit string results as raw text (no JSON-quoting),
  matching the wire contract for function_call_output.output.
- OutputConverter FCC: validate non-empty CallId before closing the in-flight
  text message, so a skipped FCC no longer breaks output-item boundaries.
- ToolApprovalIdMap.Record: take pre-serialized arguments JSON (string) and
  primitive callId/name. Drops [RequiresUnreferencedCode]/[RequiresDynamicCode]
  so trim/AOT warnings stop propagating to call sites.
- ToolApprovalIdMap.Record: no-op when callId or name is empty.
- Tests: dedup duplicate ConvertItemsToMessages_McpApprovalResponse no-mapping
  test; add coverage for empty-CallId boundary, raw-string FRC payload, and
  Record empty-key no-op.

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-05-07 00:30:41 +00:00
e56e6dad4d Python: Remove bespoke Foundry toolbox helpers; standardize on MCP for toolbox consumption (#5671)
* Remove Foundry toolbox helpers; standardize on MCP for toolbox consumption

- Remove RawFoundryChatClient.get_toolbox() and its fetch_toolbox import
- Remove fetch_toolbox, select_toolbox_tools, get_toolbox_tool_name,
  get_toolbox_tool_type, FoundryHostedToolType, ToolboxToolSelectionInput
  from agent_framework_foundry._tools
- Remove ExperimentalFeature.TOOLBOXES from _feature_stage.py (no consumers)
- Drop toolbox re-exports from agent_framework_foundry/__init__.py and
  agent_framework.foundry namespace
- Update _sanitize_foundry_response_tool docstring to remove toolbox framing;
  sanitization logic itself is unchanged
- Update _agent.py docstring: 'toolbox-fetched MCP' → 'hosted MCP'
- Delete tests/test_toolbox.py (all tests covered removed helpers)
- Update test_foundry_chat_client.py: rename/redoc tests that mentioned
  toolbox but test sanitization that remains
- Delete foundry_chat_client_with_toolbox.py (bespoke toolbox API sample)
- Delete foundry_toolbox_context_provider.py (relied on select_toolbox_tools)
- Rename foundry_chat_client_with_toolbox_mcp.py →
  foundry_chat_client_with_toolbox.py (canonical MCP pattern)
- Rewrite 04_foundry_toolbox/main.py to use MCPStreamableHTTPTool
- Update provider/README, context_providers/README, 04_foundry_toolbox/README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(samples): update 06_files sample to consume toolbox via MCP (#5670)

Replace removed get_toolbox/select_toolbox_tools APIs with
MCPStreamableHTTPTool, using allowed_tools=["code_interpreter"] to
select only the code interpreter from the toolbox endpoint.

Update .env.example and README to use FOUNDRY_TOOLBOX_ENDPOINT
instead of TOOLBOX_NAME.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(foundry): remove non-existent toolbox helper APIs from README (#5670)

Remove the 'fetch, optionally filter, and pass tools directly' pattern
from the FoundryChatClient toolbox documentation, as select_toolbox_tools
and get_toolbox were removed. Only the MCP endpoint pattern is documented.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(foundry): remove residual toolbox docstring references and reproduction report

Remove REPRODUCTION_REPORT.md (workflow artifact that should not be committed),
and update two remaining docstring references that still said 'toolbox reads'
/'toolbox definition' after the toolbox helpers were removed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Remove bespoke Foundry toolbox helpers; standardize on MCP for toolbox consumption

Fixes #5670

* fix(#5670): resolve toolbox endpoint from TOOLBOX_NAME fallback; add namespace regression tests

- Add _resolve_toolbox_endpoint() helper in 04_foundry_toolbox/main.py and
  06_files/main.py that prefers FOUNDRY_TOOLBOX_ENDPOINT but falls back to
  deriving the MCP URL from FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME — fixing
  the startup KeyError when agents are deployed via azd provision (which injects
  TOOLBOX_NAME, not FOUNDRY_TOOLBOX_ENDPOINT).
- Update 04_foundry_toolbox/.env.example to use FOUNDRY_TOOLBOX_ENDPOINT
  (consistent with 06_files).
- Add TOOLBOX_NAME env var to 06_files/agent.yaml so deployed agents have it
  available for the fallback derivation.
- Update both READMEs to document the two ways to supply the toolbox endpoint.
- Add test_foundry_namespace_no_longer_exposes_toolbox_helpers() with negative
  assertions for FoundryHostedToolType, get_toolbox_tool_name,
  get_toolbox_tool_type, and select_toolbox_tools — guarding against accidental
  re-introduction of removed symbols.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(samples): fail fast on empty FOUNDRY_TOOLBOX_ENDPOINT; add unit tests

Addresses review feedback for #5670:

- In _resolve_toolbox_endpoint() (04_foundry_toolbox/main.py and
  06_files/main.py) change the walrus-operator check from a truthy
  test to an explicit 'is not None' guard.  An explicitly set empty
  string now raises ValueError immediately with a clear message
  instead of silently falling through to the fallback URL
  construction.

- Add tests/samples/hosting/test_toolbox_endpoint.py covering both
  sample modules:
    (a) FOUNDRY_TOOLBOX_ENDPOINT set → returned as-is
    (b) FOUNDRY_TOOLBOX_ENDPOINT set to empty string → ValueError
    (c) fallback constructs URL from FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME,
        stripping trailing slashes
    (d) neither variable group set → KeyError

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback: remove extraneous test and docstring content

- Remove test_foundry_namespace_no_longer_exposes_toolbox_helpers (no longer warranted)
- Remove docstring from _agent.py _prepare_tools_for_openai (extraneous)
- Trim _chat_client.py _prepare_tools_for_openai docstring to one-liner (toolbox references no longer relevant)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: remove remaining extraneous docstring from RawFoundryChatClient._prepare_tools_for_openai

Address review comment on PR #5671: reviewer noted the description
isn't warranted now that toolbox helpers have been removed. Matches
the pattern in RawFoundryAgentChatClient which has no docstring.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 23:56:16 +00:00
51ad460d5f .NET: Add Foundry.Hosting.IntegrationTests (#5598)
* Foundry.Hosting.IntegrationTests: scaffold project, fixtures, and 24 tests

Add a new integration test project for Foundry hosted agents alongside the existing Foundry.IntegrationTests project. The project provisions a real Foundry hosted agent per scenario via AgentAdministrationClient.CreateAgentVersionAsync, points it at a single test container image (built and pushed out of band by scripts/it-build-image.ps1 in a follow up commit), and exercises the agent through AIProjectClient.AsAIAgent.

Six scenario fixtures are introduced, each pointing at the same image but selecting behavior via the IT_SCENARIO environment variable on the HostedAgentDefinition:
- HappyPathHostedAgentFixture (round trip, multi turn, stored=false flag)
- ToolCallingHostedAgentFixture (server side AIFunctions)
- ToolCallingApprovalHostedAgentFixture (approval flow)
- ToolboxHostedAgentFixture (Foundry toolbox)
- McpToolboxHostedAgentFixture (MCP backed toolbox)
- CustomStorageHostedAgentFixture (custom storage provider)

24 tests across 6 test classes are scaffolded. All are tagged Skip pending the test container build and the end to end smoke iteration in follow up commits. Once the container is in place the Skip annotations can be removed scenario by scenario.

Adds an IT_HOSTED_AGENT_IMAGE constant to the shared TestSettings so every IT project agrees on the env var name the build script emits.

* Foundry.Hosting.IntegrationTests: add TestContainer, build script, slnx, README

Adds the rest of the integration test infrastructure on top of the previous scaffolding commit:

* Foundry.Hosting.IntegrationTests.TestContainer csproj and Program.cs implementing the multi scenario container (one image, IT_SCENARIO env var dispatches between happy-path, tool-calling, tool-calling-approval, toolbox, mcp-toolbox, and custom-storage). The toolbox, mcp-toolbox, and custom-storage branches are placeholders pending API surface stabilization.
* Dockerfile and dockerignore in the test container project, using the contributor pattern matching the investigation work (host side dotnet publish, container only does COPY out/).
* scripts/it-build-image.ps1 with mandatory Registry parameter (no hardcoded ACR), content hashed tags so unchanged source results in a no op push, and emits IT_HOSTED_AGENT_IMAGE for shells and CI to consume.
* slnx entry for both new projects.
* README in the IT project covering env vars, image build, scenario table, and current placeholder status.

Steps still pending: end to end smoke (step 5) and CI workflow integration (step 6) require a live Foundry deployment and ACR push, so they land in follow up commits.

* Foundry.Hosting.IntegrationTests: address PR 5598 review feedback

Fix issues raised by Copilot review:

* it-build-image.ps1: hash file contents, not the path list, so any source edit produces a fresh tag. Normalize Registry input by stripping scheme and trailing slash before deriving the ACR short name. Validate the short name is non empty.
* HostedAgentFixture: route GetAgentAsync through _adminClient (which has the FoundryFeaturesPolicy attached) instead of through _projectClient.AgentAdministrationClient (which does not).
* HostedAgentFixture FoundryFeaturesPolicy: replace Headers.Add with Remove plus Add so retries cannot accumulate duplicate headers.
* HappyPath, ToolCalling, ToolCallingApproval, CustomStorage tests: create the AgentSession before turn 1 and reuse it for both turns. The previous pattern created the session after turn 1 so turn 2 had no link to turn 1, defeating the multi turn assertion.

* .NET: Foundry.Hosting.IntegrationTests: constrain to net10.0 + dotnet format autofix

- Set <TargetFrameworks>net10.0</TargetFrameworks>: the project references both
  Microsoft.Agents.AI.Foundry.Hosting (net8/9/10 only) and AgentConformance.IntegrationTests
  (net10.0;net472 — inherits the tests-default TFM list). The intersection is net10.0;
  the previous $(TargetFrameworksCore) triple caused NU1702 + System.Text.Json version
  conflicts on the net8.0/net9.0 builds because AgentConformance had no matching asset.
- Apply `dotnet format` autofix on the test files (IDE0005, IDE0009, IDE0032, IMPORTS).

* .NET: Foundry.Hosting.IntegrationTests.TestContainer/Program.cs: add UTF-8 BOM

CI's check-format requires charset=utf-8-bom per .editorconfig.

* Foundry.Hosting IntegrationTests: wire end-to-end CI flow against hosted agents

Make the integration tests usable end-to-end against a live Foundry deployment, including
a per-run rebuild of the test container so framework code changes are exercised.

Fixture (HostedAgentFixture.cs)

* Switch from per-run unique agent names to stable scenario-keyed names (it-happy-path,
  it-tool-calling, ...). The agent's managed identity carries the Azure AI User role on
  the project scope, which is required for inbound inference; deleting the agent recycles
  the MI and breaks that role assignment, so we keep the agent across runs and only churn
  versions.
* Add IT_RUN_ID env var to defeat Foundry's content-addressed version dedup; otherwise a
  rerun just receives the existing version and Dispose deletes it.
* PATCH the per-agent endpoint with AgentEndpointConfig (Responses protocol, version
  selector at 100% to the new version). Without this, /agents/{name}/endpoint/protocols/
  openai/responses returns HTTP 400.
* Build a per-agent ProjectOpenAIClient (not the cached projectClient.ProjectOpenAIClient,
  which is bound to the project-level URL); set AgentName in options so the URL routes
  through the agent endpoint, and add the Foundry-Features header to the inference
  pipeline.
* Use Versions (which serializes to container_protocol_versions) instead of the
  deprecated ProtocolVersions; the server now rejects the legacy field.
* On Dispose, delete only the version this fixture created. Never delete the agent.

Tests

* Tag every HostedAgentTests class with [Trait("Category", "FoundryHostedAgents")] so the
  CI workflow can route them to a separate Foundry project than the rest of the
  integration suite.

CI workflow (.github/workflows/dotnet-build-and-test.yml)

* Add a foundryHosting paths-filter covering Microsoft.Agents.AI.Foundry.Hosting and its
  in-repo dependency chain (Foundry, Agents.AI, Agents.AI.Abstractions), the test
  container, the test fixture, Directory.Packages.props, the build script, and this
  workflow file. Skip the costly hosted-agent steps when none of those changed.
* Add "Build and push Foundry Hosted Agents test container" step that invokes
  scripts/it-build-image.ps1 against vars.IT_HOSTED_AGENT_REGISTRY and pipes the resulting
  IT_HOSTED_AGENT_IMAGE=<tag> into GITHUB_ENV.
* Add "Run Foundry Hosted Agents Integration Tests" step that filters in only the new
  trait, with AZURE_AI_PROJECT_ENDPOINT/AZURE_AI_MODEL_DEPLOYMENT_NAME pointed at
  IT_HOSTED_AGENT_PROJECT_ENDPOINT/IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME (Tao project,
  East US 2; the SK IT project's region does not yet support hosted agents preview).
* Exclude the new trait from the existing "Run Integration Tests" step.
* TEMP: drop the != 'pull_request' guard on the new steps and on Azure CLI Login when the
  paths-filter triggers, so PR #5598 can validate the wiring before promoting to merge
  queue only. Restore the original guard after one green PR run.

Build script (scripts/it-build-image.ps1)

* Hash now spans TestContainer source AND its referenced framework projects so any
  framework code change forces a fresh tag and a real docker push; the previous
  TestContainer-only hash silently reused stale images on framework edits.

Bootstrap script (dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1)

* New idempotent script that creates the six stable scenario agents and grants Azure AI
  User on the project scope to each agent's MI. Run once per Foundry project. Includes
  AAD-graph propagation retries because newly created MIs take time to appear there.

README (dotnet/tests/Foundry.Hosting.IntegrationTests/README.md)

* Document the bootstrap prerequisite, the regional caveat (East US 2 is the only region
  we have validated; East US returned "Unsupported region" at the time of writing), the
  per-run image rebuild, and the CI wiring including the SP RBAC requirements.

SDK pin (TEMP)

* Bump Microsoft.Agents.AI.Foundry.Hosting's Azure.AI.Projects VersionOverride to
  2.1.0-alpha.20260505.1 from the azure-sdk public daily feed (added to nuget.config).
  This release is the first that builds the per-agent inference URL as
  /agents/{name}/endpoint/protocols/openai (the 2.1.0-beta.1 release builds
  .../openai/openai/v1, which the server rejects). Revert both the feed and the override
  once the URL fix lands in a stable Azure.AI.Projects release.

* Foundry.Hosting IntegrationTests: revert alpha SDK pin; move endpoint PATCH to bootstrap

The alpha SDK pin (Azure.AI.Projects 2.1.0-alpha.20260505.1 from the azure-sdk public
daily feed) was needed only for the URL routing fix and the strongly-typed
AgentEndpointConfig/PatchAgentOptions wrapper. We do not need either right now: the
fixture stays compatible with the public 2.1.0-beta.1 by moving the one-time endpoint
PATCH to the bootstrap script (it sets version_selector to FixedRatio @latest, so each
new fixture run becomes the served version automatically without a per-run PATCH from
the test code). The hosted-agent invocation path will start working end-to-end once the
URL routing fix lands in a stable Azure.AI.Projects release; until then the tests stay
[Fact(Skip = ...)] as documented.

* Revert dotnet/nuget.config: drop the azure-sdk-for-net public feed.
* Revert Microsoft.Agents.AI.Foundry.Hosting.csproj VersionOverride to 2.1.0-beta.1.
* Revert Microsoft.Agents.AI.Foundry.UnitTests and Microsoft.Agents.AI.Foundry.Hosting.UnitTests
  Azure.AI.Projects pin (they had been bumped to align Azure.Core 1.54 transitive).
* Drop the AgentEndpointConfig PATCH block from HostedAgentFixture.cs (the type is
  alpha-only). Replace with a comment pointing at the bootstrap script.
* Bootstrap script (it-bootstrap-agents.ps1) now also PATCHes each agent's endpoint
  with version_selector=@latest if not already set. Idempotent.

* Foundry.Hosting IntegrationTests: drop accidentally committed filtered.slnx

* Foundry.Hosting IntegrationTests: revert TEMP PR override on Azure CLI Login + IT steps

The previous attempt to validate the new hosted-agent IT wiring on PR #5598 failed
because the PR is from a fork (rogerbarreto/agent-framework-public). GitHub never passes
environment secrets to fork PRs regardless of event-name guards on individual steps,
so 'azure/login@v2' fails with 'client-id and tenant-id are not supplied'. Restore the
original github.event_name != 'pull_request' guard. The new steps will execute on
push to main and on merge_group runs.

* Foundry.Hosting IntegrationTests: invoke build-and-push script with absolute path

The pwsh shell on the GitHub Actions runner couldn't resolve ./scripts/it-build-image.ps1
when the step had no working-directory set; the step inherits the runner's PWD which is
not always the repo root after preceding steps. Use github.workspace explicitly to remove
the ambiguity.

* Foundry.Hosting IntegrationTests: move it-build-image.ps1 inside the IT project tree

The previous location at scripts/it-build-image.ps1 lived outside the sparse-checkout
paths the workflow uses (.github, dotnet, python, declarative-agents), so the runner
never had the file when the new step tried to invoke it. Move the script next to its
sibling it-bootstrap-agents.ps1 inside the IT project tree, and anchor its relative
paths to the repo root via  so callers can invoke it from any PWD.

* Move scripts/it-build-image.ps1 -> dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1
* Add Push-Location to the resolved repo root inside the script (Pop-Location in finally)
  so the existing relative paths (TestContainerProject, hashed src dirs) keep working
  no matter where the script is invoked from.
* Update the workflow path filter and the step's invocation path to the new location.

* Foundry.Hosting IntegrationTests: enable 5 HappyPath tests on the live Foundry endpoint

The fixture already constructs ProjectOpenAIClient via the per-agent path that beta.1
supports (new ProjectOpenAIClient(uri, cred, opts { AgentName })), so no SDK pin bump
is required to run the smoke tests end-to-end. Un-skip the 5 tests that pass against
the live test container.

Tests un-skipped (verified passing locally against tao-foundry-prj):

* RunAsync_ReturnsNonEmptyTextAsync
* RunStreamingAsync_YieldsAtLeastOneUpdateAsync
* MultiTurn_WithPreviousResponseId_PreservesContextAsync
* StoredFalse_Baseline_DoesNotPersistResponseAsync
* Instructions_FromContainerDefinition_AreObeyedAsync

Tests still skipped with a more specific reason (4 of 9 in HappyPath plus all
ToolCalling*, McpToolbox, Toolbox, CustomStorage) because the test container does not
yet emit usable response_id / conversation_id chains, and the placeholder scenarios are
not implemented in the test container's Program.cs. These are test container limitations,
not infra bugs, and can be un-skipped as the container surfaces stabilize.

* Foundry.Hosting IntegrationTests: extract hosted IT into parallel job, add Workflows dep

Address Wesley's review feedback on PR #5598:

1. Pull Foundry hosted-agent IT into its own dotnet-foundry-hosted-it job that runs in parallel to dotnet-build and dotnet-test. Same path-filter gate keeps it skipped on unrelated edits. Builds only the filtered solution containing Foundry.Hosting.IntegrationTests and src deps. dotnet-build-and-test-check now waits on it too.

2. Add Microsoft.Agents.AI.Workflows to the foundryHosting paths-filter and to hashedDirs in it-build-image.ps1 since Foundry.Hosting transitively depends on it.

TFM constraint on the IT csproj stays at net10.0 because AgentConformance.IntegrationTests targets net10/net472 and is consumed by ~12 other IT projects on net472.

---------

Co-authored-by: Roger Barreto <rbarreto@microsoft.com>
2026-05-06 16:08:15 +00:00
Peter IbekweandGitHub 65455751a4 .NET: Fix flaky declarative test (#5669)
* Fix flaky declarative test

* Addressed host gating and guid parsing concerns in test file.
2026-05-06 15:07:23 +00:00
Roger BarretoandGitHub b12109b7e4 .NET: Bump MEAI to 10.5.1 and add Foundry per-call x-client header support (#5652)
* Bump MEAI to 10.5.1 and add per-call x-client header support

Replaces the brittle UserAgentResponsesClient subclass with a clean
per-call x-client-* header pipeline built on the new Microsoft.Extensions.AI
10.5.1 OpenAIRequestPolicies hook.

Public surface (Microsoft.Agents.AI.Foundry, [Experimental(MAAI001)]):
* chatOptions.WithClientHeader(name, value) and .WithClientHeaders(IEnumerable)
  validate the x-client- prefix (case-insensitive), apply all-or-nothing on
  bulk, and throw InvalidOperationException on foreign-typed slot collision
* myAgent.AsBuilder().UseClientHeaders().Build() opts a customer-built agent
  into the pipeline; idempotent via agent.GetService<ClientHeadersAgent>()
* Foundry-built agents (FoundryAgent.Create*) pre-wire automatically

Internals:
* ClientHeadersAgent decorator snapshots the dict at scope-push time so
  concurrent runs sharing a ChatOptions reference do not leak headers
* ClientHeadersScope is an AsyncLocal<IReadOnlyDictionary<string,string>?>
  with LIFO push/dispose semantics
* ClientHeadersPolicy singleton stamps headers via Headers.Set so per-call
  values overwrite any same-name header from earlier policies and so
  duplicate registration is value-stable
* OpenAIRequestPoliciesReflection dedups against MEAI's private _entries
  field and falls back to AddPolicy on any reflection failure; a CI test
  asserts the field shape on every MEAI bump

Hosting cleanup:
* Deleted UserAgentResponsesClient and its dummy throwing pipeline
* HostedAgentUserAgentPolicy is now registered via OpenAIRequestPolicies
  in FoundryHostingExtensions.TryApplyUserAgent

Tests:
* 19 new unit tests in ClientHeadersExtensionsTests.cs covering validation,
  AsyncLocal isolation, snapshot semantics, end-to-end wire stamping, and
  shared-chat-client dedup
* Updated OpenTelemetryAgentTests for MEAI 10.5.1 changes to web_search
  serialization and the reduced tool definition payload when sensitive
  data capture is disabled

Microsoft.Extensions.Compliance.Abstractions stays at 10.5.0 because no
10.5.1 release exists on nuget.org.

* Address PR review: pre-wire AsAIAgent path and dedup TryApplyUserAgent

* FoundryAgent: extract WireClientHeaders helper and call it from the
  internal (AIProjectClient, ChatClientAgent) constructor used by
  AzureAIProjectChatClientExtensions.AsAIAgent so those Foundry-built
  agents also pre-wire the x-client header pipeline.
* Foundry.Hosting TryApplyUserAgent: dedup HostedAgentUserAgentPolicy
  registration per OpenAIRequestPolicies instance via
  ConditionalWeakTable so per-request resolution does not grow the
  policy list unboundedly on singleton agents.

* Add tests covering AsAIAgent pre-wire and TryApplyUserAgent dedup

Backs the PR review fixes from a4c8f91 with regression tests:
* ClientHeadersExtensionsTests: AsAIAgent_FoundryAgent_HasPreWiredClientHeadersAgent
  asserts the FoundryAgent built via AzureAIProjectChatClientExtensions.AsAIAgent
  contains a ClientHeadersAgent in its delegating chain (catches future
  regressions of the bypass).
* ClientHeadersExtensionsTests: FoundryAgent_PublicConstructor_HasPreWiredClientHeadersAgent
  covers the public constructor path the same way.
* ClientHeadersExtensionsTests: UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce
  invokes UseClientHeaders 25 times on a shared chat client and asserts via
  reflection that OpenAIRequestPolicies._entries length is exactly 1.
* HostedTryApplyUserAgentDedupTests: two tests asserting
  FoundryHostingExtensions.TryApplyUserAgent stays at one entry per
  OpenAIRequestPolicies instance after 50 calls on the same agent and across
  distinct agents on different chat clients.

* Move tests next to their SUT

Removes the dedicated HostedTryApplyUserAgentDedupTests.cs test class.
Tests are co-located with the SUT they exercise:

* FoundryAgentTests.cs gains the Constructor_PreWiresClientHeadersAgent
  and Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent
  cases, since FoundryAgent is the SUT for the pre-wire behavior.
* HostedOutboundUserAgentTests.cs gains the two TryApplyUserAgent dedup
  cases, since FoundryHostingExtensions.TryApplyUserAgent is the SUT
  it already covers.
* ClientHeadersExtensionsTests.cs keeps only the
  UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce
  case, which exercises the public ClientHeadersExtensions surface.

* Remove redundant WithCancellation on inner streaming call

ct is already passed to InnerAgent.RunStreamingAsync, so
.WithCancellation(ct) on the resulting IAsyncEnumerable is a no-op.
Caught by Sergey on PR review.

* Address PR review: surface downstream MEAI experimental ID

* Add AIOpenAIRequestPolicies = MEAIExperiments alias to
  DiagnosticIds.Experiments (matches the existing AIResponseContinuations,
  AIMcpServers, AIFunctionApprovals pattern).
* Mark public ClientHeadersExtensions with [Experimental(AIOpenAIRequestPolicies)]
  instead of AgentsAIExperiments. Consumers now see the MEAI001 warning,
  surfacing the dependency on MEAI's experimental OpenAIRequestPolicies hook.
* Mark internal OpenAIRequestPoliciesReflection with the same alias to
  suppress warnings at the source rather than via project-wide NoWarn.
* Remove MEAI001 from Foundry csproj NoWarn (kept on Foundry.Hosting where
  pre-PR usages remain).
* Clarify ClientHeadersScope XML doc: AsyncLocal flows values forward but
  does NOT auto-restore on method return; explicit using/Dispose is what
  gives stack-style LIFO semantics.
2026-05-06 14:43:08 +00:00
be8d2619e4 Python: [Breaking] Restructure agent skills to use multi-source architecture (#5584)
* migrate skills to multi source architecture

* Fix ruff lint errors in skills module (ASYNC240, SIM108, E501)

- Use anyio.Path for async file I/O in _FileSkillResource.read()
- Use noqa: ASYNC240 for pure string os.path calls in async context
- Restore pre-commit if/else pattern in InlineSkillScript.run()
- Break long lines to fit 120-char limit in _skills.py and test_skills.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: collapse multi-line lambdas to single lines to fix pyright errors

The pyright ignore comments only suppress errors on the same line, so
multi-line lambdas left arguments on continuation lines uncovered.
Collapse both lambdas to single lines matching the existing load_skill
lambda pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: replace untyped lambdas with typed inner functions to fix pyright errors

Python lambdas cannot have type annotations, so pyright reports
reportUnknownLambdaType and reportUnknownArgumentType errors that
cannot be suppressed with inline ignore comments. Replace the
lambdas for read_skill_resource and run_skill_script with typed
inner async functions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: address PR review feedback on docs and prompt template

- Update with_prompt_template() docstring to document the
  {resource_instructions} placeholder requirement
- Remove stray backslashes after {resource_instructions} and
  {runner_instructions} in DEFAULT_SKILLS_INSTRUCTION_PROMPT
- Update subprocess_script_runner docstring to reflect
  FileSkillScript.full_path usage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: replace dict[str, Skill] with Sequence[Skill] in SkillsProvider

Replace internal dict-based skills storage with Sequence[Skill] to
eliminate silent duplicate overwrites and simplify the code. Add
_find_skill helper for case-insensitive linear lookup.

Also fix pyright errors in tests by adding isinstance assertions
before accessing .function on SkillResource/SkillScript base types.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: add read-time resource path validation in _FileSkillsSource

Move security validation (path-traversal and symlink guards) for
file-based skill resources into _FileSkillsSource, restoring the
read-time checks that existed in main via _read_file_skill_resource.

- Add _get_validated_resource_path static method on _FileSkillsSource
  that validates containment, existence, and symlink safety
- _FileSkillsSource.get_skills() validates resource paths at discovery
  time via _get_validated_resource_path before passing to _FileSkillResource
- Move _normalize_resource_path, _is_path_within_directory, and
  _has_symlink_in_path from module-level into _FileSkillsSource as
  static methods (only used there)
- _FileSkillResource remains a simple path-to-content reader
- Add tests for _get_validated_resource_path security checks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: reject str/Path in SkillsProvider constructor to prevent str-as-Sequence ambiguity

Since str is a Sequence, passing a path string to the source parameter
would silently be treated as a sequence of characters instead of a
file source. Add an explicit TypeError with a helpful message pointing
callers to SkillsProvider.from_paths().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR #5584 review feedback

- Remove .NET reference from _FileSkillResource docstring
- Fix inconsistent resource name example (references/FAQ.md -> references/FAQ)
- Simplify SkillsProvider usage in code_defined_skill sample (pass single skill directly)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* remove skillsproviderbuilder

* Update python/packages/core/agent_framework/_skills.py

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>

* fix: remove dead code and fix sync function call in InlineSkillResource.read()

- Change await self.function() to self.function() for sync functions
  without **kwargs; async results are handled by inspect.isawaitable()
- Remove unreachable raise ValueError since __init__ already validates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* remove full_path unnecessary property

* replace anyio with asyncio.to_thread for file I/O in _FileSkillResource

Replace anyio.Path usage with asyncio.to_thread + pathlib.Path since
anyio is not a direct dependency of core (transitive via mcp).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* simplify awaitable check to return directly

Use 'return await result' instead of assigning then returning.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* address PR review feedback for skills refactoring

- Replace anyio with asyncio.to_thread + pathlib.Path for file I/O
- Simplify awaitable check to return directly
- Remove unnecessary function None guard in InlineSkillResource.read()
- Add assert for type narrowing on self.function

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* address PR review feedback for skills refactoring

- Replace anyio with asyncio.to_thread + pathlib.Path for file I/O
- Simplify awaitable checks to return directly
- Remove unnecessary function None guard in InlineSkillResource.read()
- Use typing.cast instead of assert for type narrowing
- Add caching behavior note to SkillsProvider docstring

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: move name/description from abstract properties to Skill.__init__

Replace abstract properties for name and description on the Skill ABC
with a base __init__ that validates and stores them as regular
attributes. This simplifies custom Skill subclasses (only content
remains abstract) and centralizes validation in the base class,
consistent with SkillResource and SkillScript base classes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-05-06 09:45:06 +00:00
Roger BarretoandGitHub 705473c276 .NET: Add hosted agent observability sample (#5660)
* .Net: Add hosted agent observability sample

Mirrors the Python sample added in #5608 for Foundry hosted agents. The
.NET hosting library already wires OpenTelemetry automatically via
Microsoft.Agents.AI.Foundry.Hosting (ApplyOpenTelemetry) plus
Azure.AI.AgentServer.Core's AddAgentHostTelemetry, so no framework
changes are needed. The sample is documentation plus a runnable artifact
that produces an interesting span tree (invoke_agent / agent_invoke /
chat / execute_tool).

Adds Hosted-Observability under FoundryHostedAgents/responses with two
small tools (GetCurrentLocation, GetWeather), agent.yaml /
agent.manifest.yaml declaring OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT
(the .NET equivalent of Python's ENABLE_SENSITIVE_DATA), Dockerfile +
Dockerfile.contributor, .env.example and README explaining the .NET vs
Python defaults. Project added to agent-framework-dotnet.slnx.

* Address PR feedback: use Random.Shared and add .dockerignore
2026-05-06 08:33:16 +00:00
f25e81701d Python: Add Python parity for InvokeMcpTool in declarative workflow (#5630)
* Add Python parity for HttpRequestAction in declarative workflow

* Ran pyupgrade and pright to fix CI issues

* Fix conversation ID dot parsing for http executor

* Removed unnecessary export command

* Initial implementation of invoke mcp tool in python

* Update sample to support require approval to be toggled by environment variable.

* Fix cache and PR comments

* Update python/samples/03-workflows/declarative/invoke_mcp_tool/main.py

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-05-05 20:16:03 +00:00
bahtyarandGitHub f3f71f0fe8 Python: fix(bedrock): don't send toolChoice when no tools are configured (#5172)
* fix(bedrock): don't send toolChoice when no tools are configured

BedrockChatClient was sending toolConfig.toolChoice even when no tools
were configured (tools=None). AWS Bedrock requires toolConfig.tools to
be present whenever toolChoice is specified, causing a 400 validation
error.

Only set toolChoice when tool_config has a 'tools' key present.

Fixes #5165

Signed-off-by: bahtya <bahtyar153@qq.com>

* test: add tests for toolChoice without tools

- test_prepare_options_tool_choice_auto_without_tools_omits_tool_config
- test_prepare_options_tool_choice_required_without_tools_omits_tool_config

Verifies that toolConfig is omitted when tool_choice is set but no
tools are provided, preventing ParamValidationError from Bedrock.

* fix: address maintainer feedback — remove stray test file, raise ValueError for required without tools

1. Remove test_addition.py — stray duplicate of tests already in
   python/packages/bedrock/tests/test_bedrock_client.py, missing all
   necessary imports and would fail with NameError.

2. Change tool_choice='required' handling to raise ValueError when no
   tools are configured instead of silently falling through. Using
   'required' without tools is a logical contradiction — the model
   must invoke a tool but none exist — so surfacing this as a
   ValueError helps callers catch the misconfiguration early.

3. Update the corresponding test to expect ValueError instead of
   silently omitted toolConfig.

---------

Signed-off-by: bahtya <bahtyar153@qq.com>
2026-05-05 19:15:37 +00:00
ddfbdf5c7a Python: information-flow control prompt injection defense (#5331)
* Python: Information-flow control based prompt injection defense (#5024)

* fides integration

* documentation

* documentation

* documentation

* human-approval on policy violation

* numenous hyena 'works'

* IFC based implementation

* minor edits in documentation

* rebasing the branch and running the email example

* Add security tests for IFC middleware

* Fix Role.TOOL NameError in approval handling

* tiered labelling scheme

* 3 tier labelling scheme in middleware

* Adapt security middleware to list[Content] tool results

* Refactor SecureAgentConfig as context provider and address Copilot review comments

* Update FIDES docs to reflect context provider pattern and update code for ContextProvider rename

* Fix security examples: use OpenAIChatClient instead of non-existent AzureOpenAIChatClient

* Address PR review: consolidate security modules, remove ContentLineage, update docs

* remove unrelated files

* remove comment from _tools.py and rename decision file

* Fix CI failures: Bandit B110, broken md links, hosted approval passthrough

* apply template to decision doc 0024

* minor fixes to decision doc 0024

---------

Co-authored-by: Aashish <t-akolluri@microsoft.com>

* Python: follow up FIDES security flow (#5330)

* Python: follow up FIDES security flow

Refine the secure approval path, mark the security classes with the FIDES experimental feature label, and clean up the related docs/tests. Also fix workspace-level validation regressions uncovered while running the full Python check suite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: remove FIDES GitHub MCP sample

Drop the GitHub MCP security sample from the FIDES follow-up branch while keeping the remaining security docs and samples intact.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review: fix paths and update FIDES implementation (#5352)

* Python: updated import naming and comment from review (#5421)

* updated import naming and comment from review

* Add approval replay None call-id test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Address PR 5331 comments and track sesssion while calling Agent in email_security_example (#5446)

* Address PR review: fix paths and update FIDES implementation

* Address PR comments and add session tracking in email example in samples

* Fix session creation and resolve merge conflict in docstring example

* Resolve merge conflict in docstring example

* Python: add test for empty-message pruning in approval result replacement (#5617)

Adds test coverage for the second-pass logic in
`_replace_approval_contents_with_results` that removes messages whose
`contents` list becomes empty after first-pass content removal.

Addresses review comment on PR #5331:
https://github.com/microsoft/agent-framework/pull/5331#discussion_r3129039445

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: shrutitople <shruti.tople@gmail.com>
Co-authored-by: Aashish <t-akolluri@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 18:08:08 +00:00
Teja KusireddyandGitHub 806075ae61 .NET: Fix YAML block scalar parsing for file skills (#5610)
* Fix YAML block scalar parsing for file skills

* Address block scalar parsing review feedback
2026-05-05 16:32:05 +00:00
Peter IbekweandGitHub d2e694dfe1 .NET: Fix QuestionExecutor looping after GotoAction re-entry in declarative workflows (#5635)
* Fix QuestionExecutor looping after GotoAction re-entry in declarative workflows

* Addressed failing integration test and promptcount
2026-05-05 15:53:31 +00:00
Jacob AlberandGitHub 6f86debb81 fix: Add missing Workflows "Shared" sources to solution (#5656) 2026-05-05 15:45:49 +00:00
Jacob AlberandGitHub 9f3f7fd03b fix: JSON Serialization issue with MultiPartyConversation (#5653)
When MultiPartyConversation gets saved during checkpointing, the data for the chat history is not persisted, resulting in failures to deserialize after. The fix is to make the history visible to the source generated serialization code.
2026-05-05 15:36:05 +00:00
westeyandGitHub e9a6d43237 .NET: Improve Todo multithreading and inject todos into message list (#5655)
* Improve Todo multithreading and inject todos into message list

* Address PR comments
2026-05-05 15:21:51 +00:00
westeyandGitHub 384e26abd7 .NET: Add allow listing for WebBrowsingTool (#5605)
* Add allow listing for WebBrowsingTool

* Address PR comments.
2026-05-05 15:20:55 +00:00
162985f2a3 .NET: feat: Implement message filtering to exclude non-portable content typ… (#5410)
* feat: Implement message filtering to exclude non-portable content types before forwarding

Co-authored-by: Copilot <copilot@github.com>

* Added unit tests to cover forwarded message filtering within AI Agent executors

Co-authored-by: Copilot <copilot@github.com>

* Update dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: Disable forwarding of incoming messages in AIAgentHostExecutor tests

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-05 14:43:45 +00:00
177 changed files with 22216 additions and 3981 deletions
+108 -1
View File
@@ -37,6 +37,7 @@ jobs:
outputs:
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
@@ -47,6 +48,21 @@ jobs:
- 'dotnet/**'
cosmosdb:
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
# The Foundry hosted-agent IT is costly (builds a container, pushes to ACR,
# provisions live agents). Only run it when the project under test, its
# dependency chain, the test container, the test fixture, or their tooling
# changed. Keep this list in sync with $hashedDirs in scripts/it-build-image.ps1.
foundryHosting:
- 'dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/**'
- 'dotnet/src/Microsoft.Agents.AI.Foundry/**'
- 'dotnet/src/Microsoft.Agents.AI/**'
- 'dotnet/src/Microsoft.Agents.AI.Abstractions/**'
- 'dotnet/src/Microsoft.Agents.AI.Workflows/**'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/**'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/**'
- 'dotnet/Directory.Packages.props'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
- '.github/workflows/dotnet-build-and-test.yml'
# run only if 'dotnet' files were changed
- name: dotnet tests
if: steps.filter.outputs.dotnet == 'true'
@@ -259,6 +275,7 @@ jobs:
--report-xunit-trx `
--ignore-exit-code 8 `
--filter-not-trait "Category=IntegrationDisabled" `
--filter-not-trait "Category=FoundryHostedAgents" `
--parallel-algorithm aggressive `
--max-threads 2.0x
env:
@@ -299,11 +316,101 @@ jobs:
shell: pwsh
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
# The Foundry hosted-agent IT is costly (it builds a container, pushes to ACR, and provisions
# live agents on a separate Foundry project). Running it in its own job keeps the overall
# workflow time roughly flat: it executes in parallel to dotnet-build and dotnet-test and is
# gated on paths-filter.outputs.foundryHostingChanges so unrelated edits skip the work.
dotnet-foundry-hosted-it:
needs: paths-filter
if: github.event_name != 'pull_request' && needs.paths-filter.outputs.foundryHostingChanges == 'true'
runs-on: ubuntu-latest
environment: integration
env:
targetFramework: net10.0
configuration: Release
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
.
.github
dotnet
python
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Generate test solution (no samples)
shell: pwsh
run: |
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
-Solution dotnet/agent-framework-dotnet.slnx `
-TargetFramework $env:targetFramework `
-Configuration $env:configuration `
-ExcludeSamples `
-OutputPath dotnet/filtered.slnx `
-Verbose
- name: Generate Foundry hosted IT filtered solution
shell: pwsh
run: |
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
-Solution dotnet/filtered.slnx `
-TargetFramework $env:targetFramework `
-Configuration $env:configuration `
-TestProjectNameFilter "Foundry.Hosting.IntegrationTests*" `
-OutputPath dotnet/filtered-foundry-hosted.slnx `
-Verbose
- name: Build Foundry hosted IT (and its deps)
shell: bash
run: dotnet build dotnet/filtered-foundry-hosted.slnx -c "$configuration" -f "$targetFramework" --warnaserror
- 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 }}
# We rebuild and push the test container image on every IT run so framework code changes
# are picked up; the image tag is content-hashed across the test container source AND its
# framework project references, so identical content is a no-op push.
- name: Build and push Foundry Hosted Agents test container
id: build-foundry-hosted-image
shell: pwsh
working-directory: ${{ github.workspace }}
run: |
$registry = "${{ vars.IT_HOSTED_AGENT_REGISTRY }}"
if ([string]::IsNullOrWhiteSpace($registry)) {
throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment."
}
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry | Tee-Object -FilePath $env:GITHUB_ENV -Append
- name: Run Foundry Hosted Agents Integration Tests
shell: pwsh
working-directory: dotnet
run: |
dotnet test --solution ./filtered-foundry-hosted.slnx `
-f $env:targetFramework `
-c $env:configuration `
--no-build -v Normal `
--report-xunit-trx `
--ignore-exit-code 8 `
--filter-trait "Category=FoundryHostedAgents"
env:
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.IT_HOSTED_AGENT_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME }}
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
dotnet-build-and-test-check:
if: always()
runs-on: ubuntu-latest
needs: [dotnet-build, dotnet-test]
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it]
steps:
- name: Get Date
shell: bash
@@ -0,0 +1,142 @@
---
status: proposed
contact: shruti
date: 2026-01-14
deciders: {}
consulted: {}
informed: {}
---
# FIDES - Deterministic Prompt Injection Defense [Costa et al., 2025]
## Context and Problem Statement
AI agents are vulnerable to prompt injection attacks where malicious instructions embedded in external content (e.g., API responses, user input) can manipulate agent behavior. Traditional defenses rely on heuristics and prompt engineering, which are not deterministic and can be bypassed.
We need a systematic, deterministic defense mechanism that prevents untrusted content from influencing agent behavior, provides verifiable security guarantees, maintains audit trails for compliance, and integrates seamlessly with the existing agent framework.
## Decision Drivers
- Agents must not execute actions influenced by untrusted external content (prompt injection defense).
- The solution must provide deterministic, verifiable security guarantees — not heuristic-based.
- The solution must maintain audit trails for compliance and security reviews.
- The solution must integrate non-invasively with the existing middleware pipeline.
- The solution must be opt-in and backwards compatible with existing agents.
- Developer experience must remain simple with a clear security model.
## Considered Options
- Information-flow control with label-based middleware (FIDES)
- Prompt engineering defense
- Content sanitization
- Separate agent instances
- Runtime monitoring only
## Decision Outcome
Chosen option: "Information-flow control with label-based middleware (FIDES)", because it is the only option that provides deterministic, formally verifiable security guarantees while integrating non-invasively with the existing middleware pipeline and remaining fully backwards compatible.
FIDES (Flow Integrity Deterministic Enforcement System) is a label-based security system with four core components:
1. **Content Labeling System** — `IntegrityLabel` (TRUSTED/UNTRUSTED) and `ConfidentialityLabel` (PUBLIC/PRIVATE/USER_IDENTITY) with most-restrictive-wins combination policy.
2. **Middleware-Based Enforcement** — `LabelTrackingFunctionMiddleware` for automatic label propagation and `PolicyEnforcementFunctionMiddleware` for pre-execution policy checks.
3. **Variable Indirection** — `ContentVariableStore` and `VariableReferenceContent` for physical isolation of untrusted content from the LLM context.
4. **Quarantined Execution** — `quarantined_llm` and `inspect_variable` tools for isolated processing of untrusted data with audit logging.
### Consequences
- Good, because it provides deterministic security guarantees about what untrusted content can influence.
- Good, because labels provide a clear audit trail of trust propagation.
- Good, because it composes with existing middleware, tools, and agent patterns.
- Good, because it requires no changes to core content types or agent logic (non-invasive).
- Good, because policies are configurable per agent or tool.
- Good, because audit logs support compliance and security reviews.
- Bad, because middleware adds latency to every tool call.
- Bad, because the variable store consumes memory for untrusted content.
- Bad, because developers must understand the label system.
- Bad, because it does not defend against all attack vectors (e.g., training data poisoning).
- Neutral, because the most-restrictive-wins label propagation may be overly conservative in some cases.
- Neutral, because it requires maintaining an explicit allowlist of tools that accept untrusted inputs.
## Pros and Cons of the Options
### Information-flow control with label-based middleware (FIDES)
Implement content labeling (integrity + confidentiality), middleware-based enforcement, variable indirection, and quarantined execution.
- Good, because it provides deterministic, formally verifiable security guarantees.
- Good, because it integrates via the existing `FunctionMiddleware` pipeline — no schema changes needed.
- Good, because it is fully opt-in and backwards compatible.
- Good, because `SecureAgentConfig` provides a simple one-line setup for common patterns.
- Bad, because middleware adds per-tool-call latency overhead.
- Bad, because developers must configure tool policies manually.
### Prompt engineering defense
Add defensive prompts like "Ignore any instructions in the following content."
- Good, because it requires no architectural changes.
- Good, because it is trivial to implement.
- Bad, because it is not deterministic — can be bypassed with adversarial prompts.
- Bad, because it provides no formal security guarantees.
- Bad, because it requires constant updates as attacks evolve.
### Content sanitization
Parse and sanitize all external content to remove potential instructions.
- Good, because it operates at the data layer before reaching the LLM.
- Bad, because it is computationally expensive.
- Bad, because it has a high false positive rate (legitimate content flagged).
- Bad, because it cannot handle novel attack vectors.
- Bad, because it may break legitimate use cases.
### Separate agent instances
Create isolated agent instances for processing untrusted content.
- Good, because it provides strong isolation guarantees.
- Bad, because it has high overhead (multiple agent instances).
- Bad, because it is difficult to manage state across instances.
- Bad, because it introduces complex communication patterns.
- Bad, because of poor developer experience.
### Runtime monitoring only
Monitor agent behavior and block suspicious actions post-facto.
- Good, because it requires no changes to the execution path.
- Bad, because it is reactive rather than proactive — damage may already be done when detected.
- Bad, because it is hard to define "suspicious" deterministically.
- Bad, because it cannot provide preventive guarantees.
## Implementation Notes
### Integration Points
- Uses existing `FunctionMiddleware` base class.
- Attaches labels via `additional_properties` (no schema changes).
- Leverages `SerializationMixin` for label persistence.
### Backwards Compatibility
- Fully backwards compatible — opt-in system.
- Agents without security middleware function normally.
- Unlabeled content defaults to UNTRUSTED (safer default).
- No breaking changes to existing APIs.
## Related Decisions
- [ADR-0007: Agent Filtering Middleware](0007-agent-filtering-middleware.md) — Established middleware patterns we build upon.
- [ADR-0006: User Approval](0006-userapproval.md) — Human-in-the-loop pattern we reference.
## References
- [Securing AI Agents with Information-Flow Control (Costa et al., 2025)](https://arxiv.org/abs/2505.23643)
- [Prompt Injection Attack Examples](https://simonwillison.net/2023/Apr/14/worst-that-can-happen/)
- [Information Flow Control](https://en.wikipedia.org/wiki/Information_flow_(information_theory))
- [Taint Analysis](https://en.wikipedia.org/wiki/Taint_checking)
- [Defense in Depth](https://en.wikipedia.org/wiki/Defense_in_depth_(computing))
- [ ] Performance Benchmarks
- [ ] User Acceptance Testing
@@ -0,0 +1,352 @@
# FIDES Implementation Summary
## Overview
**FIDES** is a comprehensive deterministic prompt injection defense system for the agent framework. The implementation provides label-based security mechanisms to defend against prompt injection attacks by tracking integrity and confidentiality of content throughout agent execution.
**🚀 Key Features:**
- **Context Provider Pattern** - `SecureAgentConfig` extends `ContextProvider`, injecting tools, instructions, and middleware automatically
- **Automatic Variable Hiding** - UNTRUSTED content is automatically hidden without requiring manual intervention
- **Per-Item Embedded Labels** - Tools return `list[Content]` with `Content.from_text()` for proper label propagation
- **SecureAgentConfig** - One-line secure agent configuration via `context_providers=[config]`
- **Data Exfiltration Prevention** - `max_allowed_confidentiality` prevents sensitive data leakage
- **Message-Level Label Tracking** (Phase 1) - Track labels on every message in the conversation
## Architecture Components
The FIDES defense system consists of seven main components:
1. **Content Labeling Infrastructure** - Labels for tracking integrity and confidentiality
2. **Label Tracking Middleware** - Automatically assigns, propagates labels, and hides untrusted content
3. **Per-Item Embedded Labels** - Tools can return mixed-trust data with per-item security labels
4. **Policy Enforcement Middleware** - Blocks tool calls that violate security policies
5. **Security Tools** - Specialized tools for safe handling of untrusted content (`quarantined_llm`, `inspect_variable`)
6. **SecureAgentConfig** - Context provider for easy secure agent configuration
7. **Message-Level Label Tracking** - Track labels on every message in the conversation (Phase 1)
## Implementation Details
### Files Created
1. **`python/packages/core/agent_framework/security.py`** (~2950 lines — all security primitives, middleware, tools, and configuration in a single public module)
- `IntegrityLabel` enum (TRUSTED/UNTRUSTED)
- `ConfidentialityLabel` enum (PUBLIC/PRIVATE/USER_IDENTITY)
- `ContentLabel` class with serialization support
- `combine_labels()` function for label composition
- `ContentVariableStore` for client-side content storage
- `VariableReferenceContent` for variable indirection
- `LabeledMessage` class (inherits from `Message`) for message-level tracking
- `check_confidentiality_allowed()` helper for data exfiltration prevention
- `LabelTrackingFunctionMiddleware` - Tracks and propagates security labels
- `PolicyEnforcementFunctionMiddleware` - Enforces security policies
- `SecureAgentConfig` extends `ContextProvider` - automatic secure agent configuration
- `quarantined_llm()` - Isolated LLM calls with labeled data
- `inspect_variable()` - Controlled variable content inspection
- `store_untrusted_content()` - Helper for manual variable indirection (legacy)
- `get_security_tools()` - Returns list of security tools
- `SECURITY_TOOL_INSTRUCTIONS` - Detailed guidance for agents
2. **`FIDES_DEVELOPER_GUIDE.md`** (~1250 lines)
- Located at `python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md`
- Complete documentation of the FIDES security system
- Architecture overview and design rationale
- Usage examples (6+ comprehensive scenarios)
- Best practices and configuration options
- API reference with full parameter documentation
- Data exfiltration prevention documentation
3. **`python/packages/core/tests/test_security.py`** (~800+ lines)
- Unit tests for ContentLabel and label operations
- Tests for ContentVariableStore functionality
- Tests for VariableReferenceContent
- Middleware behavior tests (label tracking and policy enforcement)
- Automatic hiding tests
- Per-item embedded label tests
- Context label tracking tests
- Message-level tracking tests (Phase 1)
- Data exfiltration prevention tests
4. **`docs/decisions/0024-prompt-injection-defense.md`**
- Architecture Decision Record (ADR)
- Design rationale and alternatives considered
- Security properties and guarantees
5. **`python/samples/02-agents/security/README.md`**
- Sample-focused entry point for the two runnable FIDES security samples
- Prerequisites, run commands, and links to the developer guide for deeper details
### Files Modified
1. **`python/packages/core/agent_framework/__init__.py`**
- Removed root-level security exports so `agent_framework.security` is the canonical import surface
## Core Features
### 1. Content Labeling Infrastructure
- **IntegrityLabel**: TRUSTED (user input) vs UNTRUSTED (AI-generated, external)
- **ConfidentialityLabel**: PUBLIC, PRIVATE, USER_IDENTITY
- **Label Combination**: Most restrictive policy (UNTRUSTED + metadata merging)
- **Serialization**: Full support for `to_dict()` and `from_dict()`
### 2. Per-Item Embedded Labels
Tools returning mixed-trust data embed labels on individual items using `Content.from_text()`:
```python
import json
from agent_framework import Content, tool
@tool(description="Fetch emails from inbox")
async def fetch_emails(count: int = 5) -> list[Content]:
return [
Content.from_text(
json.dumps({
"id": email["id"],
"body": email["body"],
}),
additional_properties={
"security_label": {
"integrity": "trusted" if email["internal"] else "untrusted",
"confidentiality": "private",
}
),
)
for email in emails
]
```
These embedded labels are automatically consumed by `LabelTrackingFunctionMiddleware`, which:
- Extracts the `security_label` from `additional_properties`
- Uses the embedded label as the highest-priority source for that item
- Automatically hides UNTRUSTED items in the variable store
- Replaces hidden items with `VariableReferenceContent` in the LLM context
- Preserves TRUSTED items visible to the LLM without tainting the context label
This enables tools to return mixed-trust data where some items (internal emails) remain visible while untrusted items (external emails) are automatically hidden without manual intervention.
},
)
for email in emails
]
```
### 3. Automatic Variable Hiding
This feature automatically hides any UNTRUSTED content returned by tools while keeping the hiding logic transparent to the developer. Developers do not need to manually call `store_untrusted_content()`. This allows the LLM /agent's context to remain clean and secure. Key aspects include:
- **Automatic Detection**: Middleware checks integrity label after each tool call
- **Automatic Storage**: UNTRUSTED results/items stored in variable store
- **Transparent Replacement**: LLM context receives `VariableReferenceContent`
- **Context Label Protection**: Hidden content does NOT taint context label
### 4. Context Label Tracking
- Context label starts as TRUSTED + PUBLIC
- Gets updated (tainted) when non-hidden untrusted content enters context
- Policy enforcement uses context label for validation
- Provides `get_context_label()` and `reset_context_label()` methods
### 5. Data Exfiltration Prevention
Tools declare `max_allowed_confidentiality` to prevent sensitive data leakage:
```python
@tool(
description="Post to public Slack channel",
additional_properties={
"max_allowed_confidentiality": "public", # Blocks PRIVATE data
}
)
async def post_to_slack(channel: str, message: str) -> dict:
return {"status": "posted"}
```
### 6. SecureAgentConfig (Context Provider)
SecureAgentConfig extends `ContextProvider` for automatic secure agent configuration:
```python
config = SecureAgentConfig(
auto_hide_untrusted=True,
allow_untrusted_tools={"search_web", "fetch_data"},
block_on_violation=True,
quarantine_chat_client=quarantine_client, # Optional: real LLM for quarantine
)
# Context provider injects tools, instructions, and middleware automatically
agent = Agent(
client=client,
name="secure_assistant",
instructions="You are a helpful assistant.",
tools=[my_tool],
context_providers=[config], # That's it!
)
```
## Security Properties
### Deterministic Defense
1. **Tiered label propagation**: Every tool result receives a label via 3-tier priority (embedded > source_integrity > input labels join)
2. **Context tracking**: Cumulative security state tracked across turns
3. **Policy enforcement**: Violations blocked before execution
4. **Content isolation**: Untrusted content stored as variables
5. **Taint propagation**: Once context becomes UNTRUSTED, it stays UNTRUSTED
6. **Data exfiltration prevention**: `max_allowed_confidentiality` gates output destinations
7. **Audit trail**: All security events logged
8. **No runtime guessing**: Deterministic label assignment
### Attack Prevention
- **Direct prompt injection**: Variables hide actual content from LLM
- **Indirect prompt injection**: Labels track untrusted AI-generated calls
- **Privilege escalation**: Policy blocks untrusted calls to privileged tools
- **Data exfiltration**: Confidentiality labels + `max_allowed_confidentiality` enforced
- **Tool misuse**: Only whitelisted tools accept untrusted inputs
## Configuration Options
### LabelTrackingFunctionMiddleware
- `default_integrity`: Default label for unknown sources
- `default_confidentiality`: Default confidentiality level
- `auto_hide_untrusted`: Enable automatic variable hiding (default: True)
- `hide_threshold`: Integrity level at which hiding occurs (default: UNTRUSTED)
### PolicyEnforcementFunctionMiddleware
- `allow_untrusted_tools`: Set of tools accepting untrusted inputs
- `block_on_violation`: Block vs warn on violations
- `enable_audit_log`: Enable/disable audit logging
### Tool Metadata (via `additional_properties`)
- `confidentiality`: Tool's output confidentiality level
- `source_integrity`: Fallback integrity for unlabeled results (data-producing tools only)
- `accepts_untrusted`: Explicit untrusted input permission
- `max_allowed_confidentiality`: Maximum allowed input confidentiality (for sink tools)
- `requires_approval`: Human-in-the-loop requirement
## Usage Pattern
### Recommended: SecureAgentConfig as Context Provider
```python
from agent_framework.security import SecureAgentConfig
config = SecureAgentConfig(
auto_hide_untrusted=True,
allow_untrusted_tools={"search_web"},
block_on_violation=True,
)
# Context provider injects everything automatically
agent = Agent(
client=client,
name="secure_assistant",
instructions="You are a helpful assistant.",
tools=[search_web],
context_providers=[config], # Tools, instructions, and middleware injected via before_run()
)
```
### Processing Hidden Content with quarantined_llm
```python
from agent_framework.security import quarantined_llm
# Agent automatically uses quarantined_llm with variable_ids
result = await quarantined_llm(
prompt="Summarize this data",
variable_ids=["var_abc123"] # Reference hidden content by ID
)
```
## Testing
Comprehensive test suite with:
- 115+ unit tests covering all components
- Label creation, serialization, combination
- Variable store operations
- Middleware behavior (tracking and enforcement)
- Automatic hiding with per-item labels
- Context label tracking
- Message-level tracking (Phase 1)
- Data exfiltration prevention
- Policy violation scenarios
- Audit log verification
Run tests:
```bash
cd python/packages/core && ../../.venv/bin/pytest tests/test_security.py -v
```
## Code Statistics
- **Total lines**: ~2,950+ lines (single `security.py` module)
- **New modules**: 1 (`security.py` — consolidated from 3 original modules)
- **Total tests**: 115+ unit tests
- **Documentation**: 1,250+ lines in developer guide
- **Examples**: 6+ comprehensive scenarios
## Deliverables Checklist
### Core Implementation
âś… ContentLabel infrastructure with integrity and confidentiality
âś… ContentVariableStore for variable indirection
âś… VariableReferenceContent for safe context references
âś… LabelTrackingFunctionMiddleware for automatic labeling
âś… PolicyEnforcementFunctionMiddleware for policy enforcement
âś… quarantined_llm tool for isolated processing
âś… inspect_variable tool for controlled content access
âś… store_untrusted_content helper for manual variable indirection
### Automatic Hiding Enhancement
âś… Auto-hide UNTRUSTED content with `auto_hide_untrusted` flag
âś… Per-middleware ContentVariableStore instances
âś… Thread-local storage for middleware access from tools
âś… Automatic UNTRUSTED content replacement
### Per-Item Embedded Labels
âś… Support for `additional_properties.security_label` on individual items
âś… Mixed-trust data handling (hide untrusted, keep trusted visible)
âś… Fallback to `source_integrity` for unlabeled items
### Context Label Tracking
âś… Cumulative context label tracking across turns
âś… Hidden content does NOT taint context
âś… `get_context_label()` and `reset_context_label()` methods
âś… Policy enforcement uses context label
### Data Exfiltration Prevention
âś… `max_allowed_confidentiality` tool property
âś… `check_confidentiality_allowed()` helper function
âś… Policy enforcement validates confidentiality flow
### SecureAgentConfig
âś… Context provider pattern with `ContextProvider` base class
âś… `before_run()` hook for automatic injection of tools, instructions, and middleware
âś… One-line secure agent configuration via `context_providers=[config]`
âś… `get_tools()`, `get_instructions()`, `get_middleware()` methods (for manual use)
âś… `quarantine_chat_client` support for real LLM calls
âś… `SECURITY_TOOL_INSTRUCTIONS` constant
### Documentation & Testing
âś… Complete FIDES Developer Guide (~1250 lines)
âś… Architecture Decision Record (ADR)
âś… Quick Start Guide
âś… Comprehensive test suite (115+ tests)
âś… Example code with 6+ scenarios
âś… 3 complete security examples (email, repo confidentiality, GitHub MCP labels)
## Summary
**FIDES** provides a comprehensive, deterministic defense against prompt injection attacks with:
- **Zero-effort protection**: Automatic variable hiding for developers
- **Context provider pattern**: `SecureAgentConfig` extends `ContextProvider` for automatic setup
- **Granular control**: Per-item embedded labels via `Content.from_text()` for mixed-trust data
- **Easy configuration**: `SecureAgentConfig` for one-line setup
- **Data safety**: Exfiltration prevention via confidentiality gates
- **Full traceability**: Message-level label tracking
- **Complete auditability**: All security events logged
The system ensures that untrusted content never directly reaches the LLM context and that all tool calls are policy-checked based on the cumulative security state before execution.
+4 -4
View File
@@ -71,12 +71,12 @@
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.1" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.1" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.4.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.4.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.3.0-preview.1.26109.11" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
@@ -98,7 +98,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
<!-- Agent SDKs -->
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.29" />
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
<!-- M365 Agents SDK -->
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
+16 -1
View File
@@ -1,4 +1,4 @@
<Solution>
<Solution>
<Configurations>
<BuildType Name="Debug" />
<BuildType Name="Publish" />
@@ -319,6 +319,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
@@ -541,6 +544,16 @@
<Folder Name="/Solution Items/src/Shared/StructuredOutput/">
<File Path="src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/Workflows/" />
<Folder Name="/Solution Items/src/Shared/Workflows/Execution/">
<File Path="src/Shared/Workflows/Execution/README.md" />
<File Path="src/Shared/Workflows/Execution/WorkflowFactory.cs" />
<File Path="src/Shared/Workflows/Execution/WorkflowRunner.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/Workflows/Settings/">
<File Path="src/Shared/Workflows/Settings/Application.cs" />
<File Path="src/Shared/Workflows/Settings/README.md" />
</Folder>
<Folder Name="/Solution Items/tests/">
<File Path="tests/.editorconfig" />
<File Path="tests/Directory.Build.props" />
@@ -583,6 +596,8 @@
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
<Project Path="tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj" />
<Project Path="tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj" />
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
@@ -12,7 +12,9 @@ static Task<PermissionRequestResult> PromptPermission(PermissionRequest request,
Console.Write("Approve? (y/n): ");
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
string kind = input is "Y" or "YES" ? "approved" : "denied-interactively-by-user";
PermissionRequestResultKind kind = input is "Y" or "YES"
? PermissionRequestResultKind.Approved
: PermissionRequestResultKind.Rejected;
return Task.FromResult(new PermissionRequestResult { Kind = kind });
}
@@ -24,5 +24,5 @@ public interface ICommandHandler
/// <param name="input">The raw user input string.</param>
/// <param name="session">The current agent session.</param>
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
bool TryHandle(string input, AgentSession session);
ValueTask<bool> TryHandleAsync(string input, AgentSession session);
}
@@ -27,17 +27,17 @@ internal sealed class ModeCommandHandler : ICommandHandler
public string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
/// <inheritdoc/>
public bool TryHandle(string input, AgentSession session)
public ValueTask<bool> TryHandleAsync(string input, AgentSession session)
{
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
{
return false;
return ValueTask.FromResult(false);
}
if (this._modeProvider is null)
{
System.Console.WriteLine("AgentModeProvider is not available.");
return true;
return ValueTask.FromResult(true);
}
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
@@ -45,7 +45,7 @@ internal sealed class ModeCommandHandler : ICommandHandler
{
string current = this._modeProvider.GetMode(session);
System.Console.WriteLine($"\n Current mode: {current}\n");
return true;
return ValueTask.FromResult(true);
}
string newMode = parts[1];
@@ -64,6 +64,6 @@ internal sealed class ModeCommandHandler : ICommandHandler
System.Console.ResetColor();
}
return true;
return ValueTask.FromResult(true);
}
}
@@ -24,7 +24,7 @@ internal sealed class TodoCommandHandler : ICommandHandler
public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
/// <inheritdoc/>
public bool TryHandle(string input, AgentSession session)
public async ValueTask<bool> TryHandleAsync(string input, AgentSession session)
{
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
{
@@ -37,7 +37,7 @@ internal sealed class TodoCommandHandler : ICommandHandler
return true;
}
var todos = this._todoProvider.GetAllTodos(session);
var todos = await this._todoProvider.GetAllTodosAsync(session).ConfigureAwait(false);
if (todos.Count == 0)
{
System.Console.WriteLine("\n No todos yet.\n");
@@ -69,7 +69,7 @@ public static class HarnessConsole
bool handled = false;
foreach (var handler in commandHandlers)
{
if (handler.TryHandle(userInput, session))
if (await handler.TryHandleAsync(userInput, session).ConfigureAwait(false))
{
handled = true;
break;
@@ -165,7 +165,8 @@ AIAgent agent =
Tools =
[
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
new WebBrowsingTool(), // Add a local web browsing tool that converts html to markdown.
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
],
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
Reasoning = new() { Effort = ReasoningEffort.Medium },
@@ -2,6 +2,7 @@
using System.ComponentModel;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;
@@ -10,11 +11,23 @@ namespace SampleApp;
/// <summary>
/// An AI function that downloads HTML pages and converts them to markdown.
/// Access is controlled by <see cref="WebBrowsingToolOptions"/> — by default, no hosts are accessible.
/// </summary>
internal sealed partial class WebBrowsingTool : AIFunction
{
private static readonly HttpClient s_httpClient = new();
private readonly AIFunction _inner = AIFunctionFactory.Create(DownloadUriAsync);
private readonly AIFunction _inner;
private readonly WebBrowsingToolOptions _options;
/// <summary>
/// Initializes a new instance of the <see cref="WebBrowsingTool"/> class.
/// </summary>
/// <param name="options">Options controlling which URLs are permitted. By default, no hosts are accessible.</param>
public WebBrowsingTool(WebBrowsingToolOptions options)
{
this._options = options ?? throw new ArgumentNullException(nameof(options));
this._inner = AIFunctionFactory.Create(this.DownloadUriAsync);
}
/// <inheritdoc/>
public override string Name => this._inner.Name;
@@ -32,7 +45,7 @@ internal sealed partial class WebBrowsingTool : AIFunction
this._inner.InvokeAsync(arguments, cancellationToken);
[Description("Fetch the html from the given url as markdown")]
private static async Task<string> DownloadUriAsync(
private async Task<string> DownloadUriAsync(
[Description("The URL to download")] string uri,
CancellationToken cancellationToken = default)
{
@@ -46,9 +59,12 @@ internal sealed partial class WebBrowsingTool : AIFunction
return $"Error: Only HTTP and HTTPS URLs are supported. Got: '{parsedUri.Scheme}'.";
}
// NOTE: In production scenarios, consider also blocking requests to private/internal IP
// ranges (e.g., 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.0.0.1, 169.254.169.254)
// to prevent SSRF attacks via prompt injection in web content.
// Check access policy.
string? accessError = await this.CheckAccessAsync(parsedUri, cancellationToken);
if (accessError is not null)
{
return accessError;
}
try
{
@@ -61,6 +77,142 @@ internal sealed partial class WebBrowsingTool : AIFunction
}
}
/// <summary>
/// Checks whether the given URI is permitted by the configured access policy.
/// Returns null if allowed, or an error message string if blocked.
/// </summary>
private async Task<string?> CheckAccessAsync(Uri uri, CancellationToken cancellationToken)
{
string host = uri.Host;
// 1. Check AllowedHosts.
if (this._options.AllowedHosts is { Count: > 0 } allowedHosts)
{
foreach (string pattern in allowedHosts)
{
if (HostMatchesPattern(host, pattern))
{
return null; // Allowed by explicit host list.
}
}
}
// 2. Short-circuit when the policy is guaranteed to block.
if (!this._options.AllowPublicNetworks &&
!this._options.AllowPrivateNetworks &&
!this._options.AllowAllHosts)
{
return $"Error: Access to '{host}' is blocked by the current access policy. Configure WebBrowsingToolOptions to allow access.";
}
// 3. Resolve DNS to determine if the host is public or private.
IPAddress[] addresses;
try
{
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
}
catch (SocketException)
{
return $"Error: Could not resolve host '{host}'.";
}
if (addresses.Length == 0)
{
return $"Error: Could not resolve host '{host}'.";
}
bool isPrivate = Array.Exists(addresses, IsPrivateAddress);
// 4. If public and AllowPublicNetworks is true → allow.
if (!isPrivate && this._options.AllowPublicNetworks)
{
return null;
}
// 5. If private and AllowPrivateNetworks is true → allow.
if (isPrivate && this._options.AllowPrivateNetworks)
{
return null;
}
// 6. If AllowAllHosts is true → allow.
if (this._options.AllowAllHosts)
{
return null;
}
// 7. Block.
string networkType = isPrivate ? "private/internal network" : "public network";
return $"Error: Access to '{host}' is blocked. The host resolves to a {networkType} address and the current access policy does not permit this. " +
"Configure WebBrowsingToolOptions to allow access.";
}
/// <summary>
/// Checks whether a host matches a pattern. Supports exact match and wildcard prefix (e.g., "*.example.com").
/// </summary>
private static bool HostMatchesPattern(string host, string pattern)
{
if (string.Equals(host, pattern, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Wildcard prefix: "*.example.com" matches "sub.example.com" and "a.b.example.com".
if (pattern.StartsWith("*.", StringComparison.Ordinal))
{
string suffix = pattern[1..]; // ".example.com"
return host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
}
return false;
}
/// <summary>
/// Determines whether an IP address is private, loopback, or link-local.
/// </summary>
private static bool IsPrivateAddress(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (IPAddress.IsLoopback(address))
{
return true;
}
if (address.AddressFamily == AddressFamily.InterNetwork)
{
byte[] bytes = address.GetAddressBytes();
return bytes[0] switch
{
10 => true, // 10.0.0.0/8
172 => bytes[1] >= 16 && bytes[1] <= 31, // 172.16.0.0/12
192 => bytes[1] == 168, // 192.168.0.0/16
169 => bytes[1] == 254, // 169.254.0.0/16 (link-local + metadata)
_ => false
};
}
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
// fe80::/10 (link-local) or fc00::/7 (unique local).
byte[] bytes = address.GetAddressBytes();
if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80)
{
return true; // Link-local
}
if ((bytes[0] & 0xfe) == 0xfc)
{
return true; // Unique local
}
}
return false;
}
/// <summary>
/// A simple HTML to Markdown converter using regex-based transformations.
/// Handles the most common HTML elements without requiring external dependencies.
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
namespace SampleApp;
/// <summary>
/// Options that control which URLs the <see cref="WebBrowsingTool"/> is permitted to access.
/// </summary>
/// <remarks>
/// <para>
/// By default, <b>no hosts are accessible</b>. You must explicitly opt in to one or more
/// of the access modes below. The validation order is:
/// </para>
/// <list type="number">
/// <item><description>If the host matches an entry in <see cref="AllowedHosts"/>, the request is allowed.</description></item>
/// <item><description>If the resolved IP is a public address and <see cref="AllowPublicNetworks"/> is <see langword="true"/>, the request is allowed.</description></item>
/// <item><description>If the resolved IP is a private/loopback/link-local address and <see cref="AllowPrivateNetworks"/> is <see langword="true"/>, the request is allowed.</description></item>
/// <item><description>If <see cref="AllowAllHosts"/> is <see langword="true"/>, the request is allowed.</description></item>
/// <item><description>Otherwise, the request is blocked.</description></item>
/// </list>
/// </remarks>
internal sealed class WebBrowsingToolOptions
{
/// <summary>
/// Gets or sets a list of host patterns that are always permitted, regardless of other settings.
/// Patterns support wildcard prefix matching (e.g., <c>"*.example.com"</c> matches <c>"docs.example.com"</c>).
/// Exact host names (e.g., <c>"docs.microsoft.com"</c>) are also supported.
/// </summary>
/// <remarks>This has the highest priority — if a host matches, it is allowed immediately.</remarks>
public IReadOnlyList<string>? AllowedHosts { get; set; }
/// <summary>
/// Gets or sets a value indicating whether public internet hosts (non-private, non-loopback, non-link-local IPs) are permitted.
/// Default is <see langword="false"/>.
/// </summary>
public bool AllowPublicNetworks { get; set; }
/// <summary>
/// Gets or sets a value indicating whether private network hosts are permitted.
/// This includes RFC 1918 addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x),
/// loopback (127.x.x.x, ::1), link-local (169.254.x.x, fe80::),
/// and cloud metadata endpoints (169.254.169.254).
/// Default is <see langword="false"/>.
/// </summary>
/// <remarks>
/// <b>Warning:</b> Enabling this allows the agent to make requests to internal services,
/// localhost, and cloud metadata endpoints. Only enable this if you understand the SSRF risks.
/// </remarks>
public bool AllowPrivateNetworks { get; set; }
/// <summary>
/// Gets or sets a value indicating whether all hosts are permitted without any restriction.
/// Default is <see langword="false"/>.
/// </summary>
/// <remarks>
/// <b>⚠️ UNSAFE:</b> Enabling this disables all network boundary checks and allows the agent
/// to access any URL, including internal services, cloud metadata endpoints, and localhost.
/// Only use this for trusted, isolated environments where SSRF is not a concern.
/// </remarks>
public bool AllowAllHosts { get; set; }
}
@@ -0,0 +1,7 @@
.env
bin/
obj/
out/
.vs/
.vscode/
*.user
@@ -0,0 +1,12 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_BEARER_TOKEN=DefaultAzureCredential
# Capture prompt / completion / tool argument content on GenAI spans.
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
# Uncomment and set to send local-run telemetry to Application Insights.
# When the agent runs inside Foundry this value is injected automatically.
#APPLICATIONINSIGHTS_CONNECTION_STRING=<your-app-insights-connection-string>
@@ -0,0 +1,17 @@
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedObservability.dll"]
@@ -0,0 +1,19 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
# which means a standard multi-stage Docker build cannot resolve dependencies outside
# this folder. Instead, pre-publish the app targeting the container runtime and copy
# the output into the container:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-observability .
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-observability -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-observability
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedObservability.dll"]
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedObservability</RootNamespace>
<AssemblyName>HostedObservability</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
</ItemGroup>
-->
</Project>
@@ -0,0 +1,108 @@
// Copyright (c) Microsoft. All rights reserved.
// Hosted Observability Agent - demonstrates that the Foundry hosting pipeline
// emits OpenTelemetry traces, metrics and logs with no extra wiring required.
// Two small tools are included so a request produces a span tree covering
// agent invocation, the chat call, and tool execution.
using System.ComponentModel;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
// Load .env file if present (for local development)
Env.TraversePath().Load();
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// ── Tools ────────────────────────────────────────────────────────────────────
string[] locations = ["New York", "London", "Paris", "Tokyo"];
string[] conditions = ["sunny", "cloudy", "rainy", "stormy"];
[Description("Get the current location of the user.")]
string GetCurrentLocation() => locations[Random.Shared.Next(locations.Length)];
[Description("Get the weather for a given location.")]
string GetWeather(
[Description("The location to get the weather for.")] string location)
=> $"The weather in {location} is {conditions[Random.Shared.Next(conditions.Length)]} with a high of {Random.Shared.Next(10, 31)}°C.";
// ── Create and host the agent ────────────────────────────────────────────────
//
// AddFoundryResponses automatically wraps `agent` with OpenTelemetryAgent
// (see Microsoft.Agents.AI.Foundry.Hosting.ServiceCollectionExtensions.ApplyOpenTelemetry)
// and the OTLP exporter is registered by Azure.AI.AgentServer.Core's
// AddAgentHostTelemetry(). No additional observability wiring is required.
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
.AsAIAgent(
model: deploymentName,
instructions: "You are a friendly assistant. Keep your answers brief.",
name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-observability",
description: "A hosted agent that demonstrates Foundry observability.",
tools: [
AIFunctionFactory.Create(GetCurrentLocation),
AIFunctionFactory.Create(GetWeather),
]);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
/// <summary>
/// A <see cref="TokenCredential"/> for local Docker debugging only.
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
/// once at startup. This should NOT be used in production.
///
/// Generate a token on your host and pass it to the container:
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
/// </summary>
internal sealed class DevTemporaryTokenCredential : TokenCredential
{
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
private readonly string? _token;
public DevTemporaryTokenCredential()
{
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
}
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> this.GetAccessToken();
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> new(this.GetAccessToken());
private AccessToken GetAccessToken()
{
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
{
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
}
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
}
}
@@ -0,0 +1,109 @@
# Hosted-Observability
A hosted [Agent Framework](https://github.com/microsoft/agent-framework) agent that demonstrates how the Foundry hosting pipeline emits OpenTelemetry traces, metrics and logs with no extra wiring.
The agent has two small tools, `GetCurrentLocation` and `GetWeather`, so an end-to-end run produces a span tree covering agent invocation, the underlying chat call, and tool execution.
## How it works
### Instrumentation is on by default
Unlike the Python SDK, the .NET hosting library is instrumented by default. `AddFoundryResponses(agent)` automatically wraps the agent with `OpenTelemetryAgent` (see `Microsoft.Agents.AI.Foundry.Hosting.ServiceCollectionExtensions.ApplyOpenTelemetry`) and the OTLP exporter pipeline is registered by `Azure.AI.AgentServer.Core`'s `AddAgentHostTelemetry()`. There is no `ENABLE_INSTRUMENTATION` flag to set.
### Sensitive content
Prompt, completion and tool argument content are omitted from spans by default. Set the OpenTelemetry standard environment variable to capture them:
```env
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
```
This is the .NET equivalent of the Python sample's `ENABLE_SENSITIVE_DATA`. It is read by `OpenTelemetryAgent.EnableSensitiveData`.
### Where the telemetry goes
Foundry injects `APPLICATIONINSIGHTS_CONNECTION_STRING` when the agent runs in the hosted environment, so traces, metrics and logs flow to Application Insights with no code change. To send telemetry from a local run, set the connection string yourself in `.env`.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
## Configuration
```bash
cp .env.example .env
```
Edit `.env` and set your Azure AI Foundry project endpoint:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability
AGENT_NAME=hosted-observability dotnet run
```
The agent starts on `http://localhost:8088`.
### Test it
```bash
azd ai agent invoke --local "What is the current weather where I am?"
```
Or with curl:
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "What is the current weather where I am?", "model": "hosted-observability"}'
```
## Expected span tree
A single request produces approximately the following spans:
| Span | Source |
|------|--------|
| `invoke_agent` | Outer span emitted by the Azure AI AgentServer hosting SDK |
| `agent_invoke <name>` | Emitted by `OpenTelemetryAgent` for each agent invocation |
| `chat <model>` | Emitted by the underlying `IChatClient` for each model call |
| `execute_tool <tool>` | Emitted for each invocation of `GetCurrentLocation` / `GetWeather` |
See the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) for the attributes captured on each span.
## Running with Docker
This project uses `ProjectReference` to the local Agent Framework source, so use `Dockerfile.contributor` with a pre-published output:
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
docker build -f Dockerfile.contributor -t hosted-observability .
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-observability \
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
--env-file .env \
hosted-observability
```
## Deploying to Foundry and viewing traces
Once deployed, telemetry flows to the Application Insights instance attached to your Foundry project. In the Foundry UI, the **Traces** tab next to **Playground** lists conversations and lets you drill into the span tree for any request.
## NuGet package users
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedObservability.csproj` for the `PackageReference` alternative.
@@ -0,0 +1,34 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-observability
displayName: "Hosted Observability Agent"
description: >
A hosted Agent Framework agent that demonstrates how the Foundry hosting
pipeline emits OpenTelemetry traces, metrics and logs to Application Insights
with no extra wiring required.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Observability
- OpenTelemetry
- Agent Framework
template:
name: hosted-observability
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
# Capture prompt / completion / tool argument content on GenAI spans.
- name: OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT
value: "true"
parameters:
properties: []
resources: []
@@ -0,0 +1,14 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-observability
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
# Capture prompt / completion / tool argument content on GenAI spans.
# See https://opentelemetry.io/docs/specs/semconv/gen-ai/ for the standard env var.
- name: OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT
value: "true"
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
@@ -55,6 +56,32 @@ internal static class AGUIChatMessageExtensions
break;
}
case AGUIReasoningMessage reasoningMessage:
{
var contents = new List<AIContent>();
if (!string.IsNullOrEmpty(reasoningMessage.Content))
{
contents.Add(new TextReasoningContent(reasoningMessage.Content)
{
ProtectedData = reasoningMessage.EncryptedValue
});
}
else if (!string.IsNullOrEmpty(reasoningMessage.EncryptedValue))
{
contents.Add(new TextReasoningContent("")
{
ProtectedData = reasoningMessage.EncryptedValue
});
}
yield return new ChatMessage(role, contents)
{
MessageId = message.Id
};
break;
}
case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
{
var contents = new List<AIContent>();
@@ -125,6 +152,12 @@ internal static class AGUIChatMessageExtensions
}
else if (message.Role == ChatRole.Assistant)
{
var reasoningMessage = MapReasoningMessage(message);
if (reasoningMessage != null)
{
yield return reasoningMessage;
}
var assistantMessage = MapAssistantMessage(jsonSerializerOptions, message);
if (assistantMessage != null)
{
@@ -144,6 +177,32 @@ internal static class AGUIChatMessageExtensions
}
}
private static AGUIReasoningMessage? MapReasoningMessage(ChatMessage message)
{
var reasoning = message.Contents.OfType<TextReasoningContent>().FirstOrDefault();
if (reasoning is null)
{
return null;
}
var text = string.Join(
string.Empty,
message.Contents.OfType<TextReasoningContent>()
.Where(r => !string.IsNullOrEmpty(r.Text))
.Select(r => r.Text));
var protectedData = message.Contents.OfType<TextReasoningContent>()
.Select(r => r.ProtectedData)
.LastOrDefault(p => !string.IsNullOrEmpty(p));
return new AGUIReasoningMessage
{
Id = message.MessageId,
Content = text,
EncryptedValue = protectedData,
};
}
private static AGUIAssistantMessage? MapAssistantMessage(JsonSerializerOptions jsonSerializerOptions, ChatMessage message)
{
List<AGUIToolCall>? toolCalls = null;
@@ -212,5 +271,6 @@ internal static class AGUIChatMessageExtensions
string.Equals(role, AGUIRoles.Assistant, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
string.Equals(role, AGUIRoles.Developer, StringComparison.OrdinalIgnoreCase) ? s_developerChatRole :
string.Equals(role, AGUIRoles.Tool, StringComparison.OrdinalIgnoreCase) ? ChatRole.Tool :
string.Equals(role, AGUIRoles.Reasoning, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
throw new InvalidOperationException($"Unknown chat role: {role}");
}
@@ -31,4 +31,18 @@ internal static class AGUIEventTypes
public const string StateSnapshot = "STATE_SNAPSHOT";
public const string StateDelta = "STATE_DELTA";
public const string ReasoningStart = "REASONING_START";
public const string ReasoningMessageStart = "REASONING_MESSAGE_START";
public const string ReasoningMessageContent = "REASONING_MESSAGE_CONTENT";
public const string ReasoningMessageEnd = "REASONING_MESSAGE_END";
public const string ReasoningEnd = "REASONING_END";
public const string ReasoningMessageChunk = "REASONING_MESSAGE_CHUNK";
public const string ReasoningEncryptedValue = "REASONING_ENCRYPTED_VALUE";
}
@@ -28,6 +28,7 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(AGUIUserMessage))]
[JsonSerializable(typeof(AGUIAssistantMessage))]
[JsonSerializable(typeof(AGUIToolMessage))]
[JsonSerializable(typeof(AGUIReasoningMessage))]
[JsonSerializable(typeof(AGUITool))]
[JsonSerializable(typeof(AGUIToolCall))]
[JsonSerializable(typeof(AGUIToolCall[]))]
@@ -46,6 +47,13 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(ToolCallResultEvent))]
[JsonSerializable(typeof(StateSnapshotEvent))]
[JsonSerializable(typeof(StateDeltaEvent))]
[JsonSerializable(typeof(ReasoningStartEvent))]
[JsonSerializable(typeof(ReasoningMessageStartEvent))]
[JsonSerializable(typeof(ReasoningMessageContentEvent))]
[JsonSerializable(typeof(ReasoningMessageEndEvent))]
[JsonSerializable(typeof(ReasoningEndEvent))]
[JsonSerializable(typeof(ReasoningMessageChunkEvent))]
[JsonSerializable(typeof(ReasoningEncryptedValueEvent))]
[JsonSerializable(typeof(IDictionary<string, object?>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(IDictionary<string, System.Text.Json.JsonElement?>))]
@@ -41,6 +41,7 @@ internal sealed class AGUIMessageJsonConverter : JsonConverter<AGUIMessage>
AGUIRoles.User => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIUserMessage))) as AGUIUserMessage,
AGUIRoles.Assistant => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIAssistantMessage))) as AGUIAssistantMessage,
AGUIRoles.Tool => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIToolMessage))) as AGUIToolMessage,
AGUIRoles.Reasoning => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIReasoningMessage))) as AGUIReasoningMessage,
_ => throw new JsonException($"Unknown AGUIMessage role discriminator: '{discriminator}'")
};
@@ -75,6 +76,9 @@ internal sealed class AGUIMessageJsonConverter : JsonConverter<AGUIMessage>
case AGUIToolMessage tool:
JsonSerializer.Serialize(writer, tool, options.GetTypeInfo(typeof(AGUIToolMessage)));
break;
case AGUIReasoningMessage reasoning:
JsonSerializer.Serialize(writer, reasoning, options.GetTypeInfo(typeof(AGUIReasoningMessage)));
break;
default:
throw new JsonException($"Unknown AGUIMessage type: {value.GetType().Name}");
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class AGUIReasoningMessage : AGUIMessage
{
public AGUIReasoningMessage()
{
this.Role = AGUIRoles.Reasoning;
}
[JsonPropertyName("encryptedValue")]
public string? EncryptedValue { get; set; }
}
@@ -17,4 +17,6 @@ internal static class AGUIRoles
public const string Developer = "developer";
public const string Tool = "tool";
public const string Reasoning = "reasoning";
}
@@ -47,6 +47,13 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent,
AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent,
AGUIEventTypes.StateSnapshot => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateSnapshotEvent))) as StateSnapshotEvent,
AGUIEventTypes.ReasoningStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningStartEvent))) as ReasoningStartEvent,
AGUIEventTypes.ReasoningMessageStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageStartEvent))) as ReasoningMessageStartEvent,
AGUIEventTypes.ReasoningMessageContent => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageContentEvent))) as ReasoningMessageContentEvent,
AGUIEventTypes.ReasoningMessageEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageEndEvent))) as ReasoningMessageEndEvent,
AGUIEventTypes.ReasoningEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningEndEvent))) as ReasoningEndEvent,
AGUIEventTypes.ReasoningMessageChunk => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageChunkEvent))) as ReasoningMessageChunkEvent,
AGUIEventTypes.ReasoningEncryptedValue => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningEncryptedValueEvent))) as ReasoningEncryptedValueEvent,
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
};
@@ -102,6 +109,27 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
case StateDeltaEvent stateDelta:
JsonSerializer.Serialize(writer, stateDelta, options.GetTypeInfo(typeof(StateDeltaEvent)));
break;
case ReasoningStartEvent reasoningStart:
JsonSerializer.Serialize(writer, reasoningStart, options.GetTypeInfo(typeof(ReasoningStartEvent)));
break;
case ReasoningMessageStartEvent reasoningMessageStart:
JsonSerializer.Serialize(writer, reasoningMessageStart, options.GetTypeInfo(typeof(ReasoningMessageStartEvent)));
break;
case ReasoningMessageContentEvent reasoningMessageContent:
JsonSerializer.Serialize(writer, reasoningMessageContent, options.GetTypeInfo(typeof(ReasoningMessageContentEvent)));
break;
case ReasoningMessageEndEvent reasoningMessageEnd:
JsonSerializer.Serialize(writer, reasoningMessageEnd, options.GetTypeInfo(typeof(ReasoningMessageEndEvent)));
break;
case ReasoningEndEvent reasoningEnd:
JsonSerializer.Serialize(writer, reasoningEnd, options.GetTypeInfo(typeof(ReasoningEndEvent)));
break;
case ReasoningMessageChunkEvent reasoningMessageChunk:
JsonSerializer.Serialize(writer, reasoningMessageChunk, options.GetTypeInfo(typeof(ReasoningMessageChunkEvent)));
break;
case ReasoningEncryptedValueEvent reasoningEncryptedValue:
JsonSerializer.Serialize(writer, reasoningEncryptedValue, options.GetTypeInfo(typeof(ReasoningEncryptedValueEvent)));
break;
default:
throw new InvalidOperationException($"Unknown event type: {value.GetType().Name}");
}
@@ -31,6 +31,7 @@ internal static class ChatResponseUpdateAGUIExtensions
string? responseId = null;
var textMessageBuilder = new TextMessageBuilder();
var toolCallAccumulator = new ToolCallBuilder();
var reasoningBuilder = new ReasoningMessageBuilder();
await foreach (var evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
{
switch (evt)
@@ -41,6 +42,7 @@ internal static class ChatResponseUpdateAGUIExtensions
responseId = runStarted.RunId;
toolCallAccumulator.SetConversationAndResponseIds(conversationId, responseId);
textMessageBuilder.SetConversationAndResponseIds(conversationId, responseId);
reasoningBuilder.SetConversationAndResponseIds(conversationId, responseId);
yield return ValidateAndEmitRunStart(runStarted);
break;
case RunFinishedEvent runFinished:
@@ -88,6 +90,36 @@ internal static class ChatResponseUpdateAGUIExtensions
yield return CreateStateDeltaUpdate(stateDelta, conversationId, responseId, jsonSerializerOptions);
}
break;
// Reasoning events (explicit lifecycle form)
case ReasoningMessageStartEvent reasoningStart:
reasoningBuilder.AddReasoningStart(reasoningStart);
break;
case ReasoningMessageContentEvent reasoningContent:
yield return reasoningBuilder.EmitReasoningContent(reasoningContent);
break;
case ReasoningMessageEndEvent reasoningEnd:
reasoningBuilder.EndCurrentMessage(reasoningEnd);
break;
// Reasoning events (chunk shorthand form)
case ReasoningMessageChunkEvent reasoningChunk:
var chunkUpdate = reasoningBuilder.EmitReasoningChunk(reasoningChunk);
if (chunkUpdate is not null)
{
yield return chunkUpdate;
}
break;
// Encrypted reasoning value (emitted by either form)
case ReasoningEncryptedValueEvent encryptedValue:
yield return reasoningBuilder.EmitEncryptedValue(encryptedValue);
break;
// ReasoningStartEvent and ReasoningEndEvent are bracket markers only — no content to emit
case ReasoningStartEvent:
case ReasoningEndEvent:
break;
}
}
}
@@ -305,6 +337,81 @@ internal static class ChatResponseUpdateAGUIExtensions
}
}
private sealed class ReasoningMessageBuilder()
{
private string? _currentMessageId;
private string? _conversationId;
private string? _responseId;
public void SetConversationAndResponseIds(string? conversationId, string? responseId)
{
this._conversationId = conversationId;
this._responseId = responseId;
}
public void AddReasoningStart(ReasoningMessageStartEvent reasoningStart)
{
if (this._currentMessageId != null)
{
throw new InvalidOperationException(
"Received ReasoningMessageStartEvent while another message is being processed.");
}
this._currentMessageId = reasoningStart.MessageId;
}
public ChatResponseUpdate EmitReasoningContent(ReasoningMessageContentEvent contentEvent)
{
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent(contentEvent.Delta)])
{
ConversationId = this._conversationId,
ResponseId = this._responseId,
MessageId = contentEvent.MessageId,
CreatedAt = DateTimeOffset.UtcNow
};
}
public ChatResponseUpdate? EmitReasoningChunk(ReasoningMessageChunkEvent chunkEvent)
{
if (string.IsNullOrEmpty(chunkEvent.Delta))
{
// Empty delta is the implicit close signal for chunk-based streaming
this._currentMessageId = null;
return null;
}
this._currentMessageId ??= chunkEvent.MessageId;
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent(chunkEvent.Delta)])
{
ConversationId = this._conversationId,
ResponseId = this._responseId,
MessageId = chunkEvent.MessageId,
CreatedAt = DateTimeOffset.UtcNow
};
}
public ChatResponseUpdate EmitEncryptedValue(ReasoningEncryptedValueEvent encryptedEvent)
{
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("") { ProtectedData = encryptedEvent.EncryptedValue }])
{
ConversationId = this._conversationId,
ResponseId = this._responseId,
MessageId = encryptedEvent.EntityId,
CreatedAt = DateTimeOffset.UtcNow
};
}
public void EndCurrentMessage(ReasoningMessageEndEvent reasoningEnd)
{
if (!string.Equals(this._currentMessageId, reasoningEnd.MessageId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Received ReasoningMessageEndEvent for a different message than the current one.");
}
this._currentMessageId = null;
}
}
private static IDictionary<string, object?>? DeserializeArgumentsIfAvailable(string argsJson, JsonSerializerOptions options)
{
if (!string.IsNullOrEmpty(argsJson))
@@ -342,6 +449,9 @@ internal static class ChatResponseUpdateAGUIExtensions
string? currentMessageId = null;
string? streamingMessageId = null;
string? currentReasoningBaseId = null;
string? currentReasoningId = null;
string? currentReasoningMessageId = null;
await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
// Generate a fallback MessageId when the provider doesn't supply one.
@@ -356,6 +466,25 @@ internal static class ChatResponseUpdateAGUIExtensions
chatResponse.Contents[0] is TextContent &&
!string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
{
// Close any open reasoning block before opening a text message, so AG-UI
// events are properly bracketed. MEAI providers share one MessageId across
// reasoning and text content, so the reasoning-block state alone wouldn't
// detect the transition.
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
currentReasoningBaseId = null;
currentReasoningId = null;
currentReasoningMessageId = null;
}
// End the previous message if there was one
if (currentMessageId is not null)
{
@@ -381,7 +510,7 @@ internal static class ChatResponseUpdateAGUIExtensions
{
yield return new TextMessageContentEvent
{
MessageId = chatResponse.MessageId!,
MessageId = currentMessageId!,
Delta = textContent.Text
};
}
@@ -393,6 +522,22 @@ internal static class ChatResponseUpdateAGUIExtensions
{
if (content is FunctionCallContent functionCallContent)
{
// Close any open reasoning block before emitting tool events.
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
currentReasoningBaseId = null;
currentReasoningId = null;
currentReasoningMessageId = null;
}
yield return new ToolCallStartEvent
{
ToolCallId = functionCallContent.CallId,
@@ -415,6 +560,22 @@ internal static class ChatResponseUpdateAGUIExtensions
}
else if (content is FunctionResultContent functionResultContent)
{
// Close any open reasoning block before emitting tool result events.
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
currentReasoningBaseId = null;
currentReasoningId = null;
currentReasoningMessageId = null;
}
yield return new ToolCallResultEvent
{
MessageId = chatResponse.MessageId,
@@ -423,6 +584,55 @@ internal static class ChatResponseUpdateAGUIExtensions
Role = AGUIRoles.Tool
};
}
else if (content is TextReasoningContent reasoningContent
&& (!string.IsNullOrEmpty(reasoningContent.Text) || !string.IsNullOrEmpty(reasoningContent.ProtectedData)))
{
if (!string.Equals(currentReasoningBaseId, chatResponse.MessageId, StringComparison.Ordinal))
{
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
}
currentReasoningBaseId = chatResponse.MessageId;
currentReasoningId = Guid.NewGuid().ToString("N");
currentReasoningMessageId = Guid.NewGuid().ToString("N");
yield return new ReasoningStartEvent
{
MessageId = currentReasoningId
};
yield return new ReasoningMessageStartEvent
{
MessageId = currentReasoningMessageId
};
}
if (!string.IsNullOrEmpty(reasoningContent.Text))
{
yield return new ReasoningMessageContentEvent
{
MessageId = currentReasoningMessageId!,
Delta = reasoningContent.Text
};
}
if (!string.IsNullOrEmpty(reasoningContent.ProtectedData))
{
yield return new ReasoningEncryptedValueEvent
{
EntityId = currentReasoningMessageId!,
EncryptedValue = reasoningContent.ProtectedData
};
}
}
else if (content is DataContent dataContent)
{
if (MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && mediaType.Equals(s_json))
@@ -476,6 +686,19 @@ internal static class ChatResponseUpdateAGUIExtensions
}
}
// End the last reasoning block if there was one
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
}
// End the last message if there was one
if (currentMessageId is not null)
{
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningEncryptedValueEvent : BaseEvent
{
public ReasoningEncryptedValueEvent()
{
this.Type = AGUIEventTypes.ReasoningEncryptedValue;
}
[JsonPropertyName("subtype")]
public string Subtype { get; set; } = "message";
[JsonPropertyName("entityId")]
public string EntityId { get; set; } = string.Empty;
[JsonPropertyName("encryptedValue")]
public string EncryptedValue { get; set; } = string.Empty;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningEndEvent : BaseEvent
{
public ReasoningEndEvent()
{
this.Type = AGUIEventTypes.ReasoningEnd;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningMessageChunkEvent : BaseEvent
{
public ReasoningMessageChunkEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageChunk;
}
[JsonPropertyName("messageId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MessageId { get; set; }
[JsonPropertyName("delta")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Delta { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningMessageContentEvent : BaseEvent
{
public ReasoningMessageContentEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageContent;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
[JsonPropertyName("delta")]
public string Delta { get; set; } = string.Empty;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningMessageEndEvent : BaseEvent
{
public ReasoningMessageEndEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageEnd;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningMessageStartEvent : BaseEvent
{
public ReasoningMessageStartEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageStart;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
[JsonPropertyName("role")]
public string Role { get; set; } = AGUIRoles.Reasoning;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningStartEvent : BaseEvent
{
public ReasoningStartEvent()
{
this.Type = AGUIEventTypes.ReasoningStart;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}
@@ -4,6 +4,7 @@ using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -18,10 +19,9 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// is already present in the <c>User-Agent</c> header, the policy does not append it again.
/// </para>
/// <para>
/// This policy is added at request time (per-call <see cref="PipelinePosition"/>)
/// by <see cref="UserAgentResponsesClient"/> when invoking the wrapped
/// <see cref="OpenAI.Responses.ResponsesClient"/>. It is only registered when an agent is
/// resolved by the Foundry hosting layer.
/// This policy is added at hosted-agent resolution time via the MEAI 10.5.1
/// <see cref="OpenAIRequestPolicies"/> hook on the agent's underlying chat client. It is only
/// registered when an agent is resolved by the Foundry hosting layer.
/// </para>
/// </remarks>
internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
@@ -233,18 +233,28 @@ internal static class InputConverter
/// <summary>
/// Converts an inbound <c>mcp_approval_response</c> wire item to a
/// <see cref="ToolApprovalResponseContent"/>. Looks up the original AF request id
/// via <see cref="ToolApprovalIdMap"/>; falls back to the wire id when the mapping
/// is unavailable. Carries a placeholder <see cref="FunctionCallContent"/> because
/// the original tool-call details are not echoed by clients in the response item.
/// <see cref="ToolApprovalResponseContent"/>. Looks up the original
/// <see cref="FunctionCallContent"/> via <see cref="ToolApprovalIdMap"/> so the
/// reconstructed response carries the original tool name, call id, and arguments.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when no mapping is recorded for <paramref name="approvalRequestId"/>.
/// Without the mapping the original call cannot be reconstructed, so we fail the request.
/// </exception>
private static ChatMessage ConvertMcpApprovalResponse(string approvalRequestId, bool approve, AgentSessionStateBag? stateBag)
{
var afRequestId = ToolApprovalIdMap.Resolve(stateBag, approvalRequestId);
var placeholderFunctionCall = new FunctionCallContent(afRequestId, "mcp_approval");
var entry = ToolApprovalIdMap.ResolveEntry(stateBag, approvalRequestId)
?? throw new InvalidOperationException(
$"No approval mapping recorded for wire id '{approvalRequestId}'.");
var functionCall = new FunctionCallContent(
entry.CallId,
entry.Name,
ParseFunctionArgumentsObject(entry.Arguments));
return new ChatMessage(
ChatRole.User,
[new ToolApprovalResponseContent(afRequestId, approve, placeholderFunctionCall)]);
[new ToolApprovalResponseContent(entry.AfRequestId, approve, functionCall)]);
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing tool-call arguments from SDK input.")]
@@ -118,8 +118,13 @@ internal static class OutputConverter
break;
}
case FunctionCallContent funcCall:
case FunctionCallContent functionCall:
{
if (functionCall.CallId is not { Length: > 0 })
{
break;
}
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
@@ -130,17 +135,15 @@ internal static class OutputConverter
accumulatedText = null;
previousMessageId = null;
var callId = funcCall.CallId ?? Guid.NewGuid().ToString("N");
var funcBuilder = stream.AddOutputItemFunctionCall(funcCall.Name, callId);
yield return funcBuilder.EmitAdded();
var arguments = funcCall.Arguments is not null
? JsonSerializer.Serialize(funcCall.Arguments)
var arguments = functionCall.Arguments is not null
? JsonSerializer.Serialize(functionCall.Arguments)
: "{}";
yield return funcBuilder.EmitArgumentsDelta(arguments);
yield return funcBuilder.EmitArgumentsDone(arguments);
yield return funcBuilder.EmitDone();
var fcBuilder = stream.AddOutputItemFunctionCall(functionCall.Name, functionCall.CallId);
yield return fcBuilder.EmitAdded();
yield return fcBuilder.EmitArgumentsDelta(arguments);
yield return fcBuilder.EmitArgumentsDone(arguments);
yield return fcBuilder.EmitDone();
break;
}
@@ -191,12 +194,19 @@ internal static class OutputConverter
// wireId↔afRequestId mapping in the session state bag for later lookup
// when the matching `mcp_approval_response` arrives on a subsequent turn.
var wireId = ToolApprovalIdMap.ComputeWireId(approvalRequest.RequestId);
ToolApprovalIdMap.Record(stateBag, wireId, approvalRequest.RequestId);
var approvalArguments = approvalFunctionCall.Arguments is not null
? JsonSerializer.Serialize(approvalFunctionCall.Arguments)
: "{}";
ToolApprovalIdMap.Record(
stateBag,
wireId,
approvalRequest.RequestId,
approvalFunctionCall.CallId,
approvalFunctionCall.Name,
approvalArguments);
var approvalItem = new OutputItemMcpApprovalRequest(
wireId,
"agent_framework",
@@ -252,10 +262,40 @@ internal static class OutputConverter
// These would need to be serialized as base64 or URL references.
break;
case FunctionResultContent:
// Function results are internal to the agent's tool-calling loop
// and are not emitted as output items in the response stream.
case FunctionResultContent functionResult:
{
if (functionResult.CallId is not { Length: > 0 })
{
break;
}
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
previousMessageId = null;
var outputText = functionResult.Result switch
{
null => string.Empty,
string s => s,
_ => JsonSerializer.Serialize(functionResult.Result),
};
var itemId = GenerateItemId("fc");
var outputItem = new OutputItemFunctionToolCallOutput(
functionResult.CallId,
BinaryData.FromString(outputText));
var outputBuilder = stream.AddOutputItem<OutputItemFunctionToolCallOutput>(itemId);
yield return outputBuilder.EmitAdded(outputItem);
yield return outputBuilder.EmitDone(outputItem);
break;
}
default:
break;
@@ -1,8 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
using Azure.AI.AgentServer.Responses;
using Azure.Core;
using Azure.Identity;
@@ -11,7 +12,6 @@ using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Shared.DiagnosticIds;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -207,84 +207,45 @@ public static class FoundryHostingExtensions
}
/// <summary>
/// Attempts to wrap the agent's underlying <see cref="ResponsesClient"/>
/// with a <see cref="UserAgentResponsesClient"/> so every outgoing Responses-API request
/// carries the hosted-agent <c>User-Agent</c> segment.
/// Registers the hosted-agent <c>User-Agent</c> supplement policy
/// (<see cref="HostedAgentUserAgentPolicy"/>) on the agent's underlying chat client via the
/// MEAI 10.5.1 <see cref="OpenAIRequestPolicies"/> hook so every outgoing OpenAI Responses
/// request carries the segment <c>foundry-hosting/agent-framework-dotnet/{version}</c>.
/// </summary>
/// <remarks>
/// <para>
/// Best-effort and idempotent. The method is a no-op when:
/// <list type="bullet">
/// <item><description><paramref name="agent"/> exposes no <see cref="IChatClient"/>;</description></item>
/// <item><description>the chat client is not backed by MEAI's internal <c>OpenAIResponsesChatClient</c> (e.g., a non-OpenAI provider or a custom impl);</description></item>
/// <item><description>the inner <see cref="ResponsesClient"/> is already a <see cref="UserAgentResponsesClient"/>.</description></item>
/// <item><description>the chat client is not OpenAI-backed (the <see cref="OpenAIRequestPolicies"/> service lookup returns <see langword="null"/>);</description></item>
/// <item><description>the policy was already registered on this client by a prior invocation (deduped via reflection on <c>OpenAIRequestPolicies._entries</c>).</description></item>
/// </list>
/// </para>
/// <para>
/// Works for any <see cref="ResponsesClient"/>-derived inner client — both the Foundry-specific
/// <see cref="Azure.AI.Extensions.OpenAI.ProjectResponsesClient"/> and the native OpenAI
/// <see cref="ResponsesClient"/> obtained from <see cref="OpenAI.OpenAIClient"/>. The wrapper preserves
/// the inner client's pipeline (Transport, RetryPolicy, NetworkTimeout, OrganizationId / ProjectId /
/// UserAgentApplicationId, custom policies) because every override delegates to the inner instance.
/// </para>
/// <para>
/// Returns the same <paramref name="agent"/> instance unchanged. Mutation happens via
/// reflection on MEAI's private <c>_responseClient</c> field; the agent itself is not wrapped.
/// Returns the same <paramref name="agent"/> instance unchanged. The policy is installed
/// on the chat client; the agent itself is not wrapped.
/// </para>
/// </remarks>
internal static AIAgent TryApplyUserAgent(AIAgent agent)
{
var chatClient = agent.GetService<IChatClient>();
if (chatClient is null)
if (chatClient?.GetService<OpenAIRequestPolicies>() is { } policies)
{
return agent;
// Hosted agents are typically singletons resolved per request, so AddPolicy must be
// called at most once per OpenAIRequestPolicies instance to avoid unbounded growth of
// the policy list (each entry adds per-request CPU work even though the User-Agent
// value stays stable). Track which instances we have already wired with a
// ConditionalWeakTable keyed on the OpenAIRequestPolicies reference; the table holds
// weak references so it does not extend the lifetime of the chat client.
if (s_userAgentRegistrations.TryAdd(policies, s_boxedTrue))
{
policies.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall);
}
}
var meaiType = s_meaiResponsesChatClientType;
if (meaiType is null)
{
return agent;
}
var meaiInstance = chatClient.GetService(meaiType);
if (meaiInstance is null)
{
return agent;
}
var field = s_meaiResponseClientField;
if (field is null)
{
return agent;
}
var current = field.GetValue(meaiInstance) as ResponsesClient;
if (current is null or UserAgentResponsesClient)
{
return agent;
}
field.SetValue(meaiInstance, new UserAgentResponsesClient(current));
return agent;
}
/// <summary>
/// MEAI's internal <c>OpenAIResponsesChatClient</c> type, resolved once via reflection.
/// <see langword="null"/> if the type cannot be found (e.g., MEAI version drift).
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
[UnconditionalSuppressMessage("Trimming", "IL2073:RequiresUnreferencedCode",
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
private static readonly Type? s_meaiResponsesChatClientType =
typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
/// <summary>
/// MEAI's internal <c>_responseClient</c> field on <c>OpenAIResponsesChatClient</c>,
/// resolved once via reflection. <see langword="null"/> if the field cannot be found.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2080:RequiresDynamicallyAccessedMembers",
Justification = "OpenAIResponsesChatClient and its private fields are preserved by the polyfill design; MEAI does the same reflection internally.")]
private static readonly FieldInfo? s_meaiResponseClientField =
s_meaiResponsesChatClientType?.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
private static readonly object s_boxedTrue = new();
private static readonly ConditionalWeakTable<OpenAIRequestPolicies, object> s_userAgentRegistrations = new();
}
@@ -4,23 +4,41 @@ using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Helper for translating between agent-framework tool-approval request ids and the
/// strict-format wire ids required by the Responses Server SDK <c>mcp_approval_request</c>
/// item type. The mapping is persisted in <see cref="AgentSessionStateBag"/> so an
/// approval request emitted on one HTTP turn can be matched to the response posted
/// back on the next turn.
/// item type, and for preserving the original <see cref="FunctionCallContent"/> across
/// the request/response round trip. The mapping is persisted in
/// <see cref="AgentSessionStateBag"/>.
/// </summary>
internal static class ToolApprovalIdMap
{
/// <summary>
/// State-bag key used to store the wire-id ↔ AF-request-id mapping.
/// State-bag key used to store the wire-id ↔ approval-entry mapping.
/// </summary>
public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap";
/// <summary>
/// Captures the data needed to reconstruct the original
/// <see cref="FunctionCallContent"/> on the inbound (response) side.
/// </summary>
/// <remarks>
/// FICC composes <c>RequestId</c> as <c>"ficc_{CallId}"</c>; <c>CallId</c> is stored
/// independently so the reconstructed function-call id matches the one the model
/// emitted and the backend Conversations API persisted.
/// </remarks>
internal sealed class ApprovalEntry
{
public string AfRequestId { get; set; } = string.Empty;
public string CallId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Arguments { get; set; }
}
/// <summary>
/// SDK item-id format constraints: <c>{prefix}_{50_or_48_chars}</c>. We use the
/// canonical <c>mcpr_</c> prefix and a SHA-256 truncated to 50 hex chars (25 bytes)
@@ -41,33 +59,81 @@ internal static class ToolApprovalIdMap
}
/// <summary>
/// Records the wire-id → AF-request-id mapping in the supplied state bag.
/// Records the wire-id → approval-entry mapping in the supplied state bag.
/// Arguments are passed as already-serialized JSON to keep this method
/// trim/AOT-friendly (no polymorphic <c>object</c> serialization here).
/// No-op when <paramref name="callId"/> or <paramref name="name"/> is empty —
/// without those fields the entry cannot be used to faithfully reconstruct
/// the original <see cref="FunctionCallContent"/> on the inbound side.
/// </summary>
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId)
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId, string? callId, string? name, string? argumentsJson)
{
if (stateBag is null)
{
return;
}
var map = stateBag.GetValue<Dictionary<string, string>>(StateBagKey)
?? new Dictionary<string, string>(StringComparer.Ordinal);
map[wireId] = afRequestId;
if (string.IsNullOrEmpty(callId) || string.IsNullOrEmpty(name))
{
return;
}
var map = LoadMap(stateBag);
map[wireId] = new ApprovalEntry
{
AfRequestId = afRequestId,
CallId = callId!,
Name = name!,
Arguments = argumentsJson,
};
stateBag.SetValue(StateBagKey, map);
}
/// <summary>
/// Looks up the AF request id for a given wire id. Returns the wire id verbatim
/// when no mapping is present (best-effort fallback that keeps converters total).
/// when no mapping is present.
/// </summary>
public static string Resolve(AgentSessionStateBag? stateBag, string wireId)
{
if (stateBag?.GetValue<Dictionary<string, string>>(StateBagKey) is { } map
&& map.TryGetValue(wireId, out var afRequestId))
if (TryLoadMap(stateBag, out var map)
&& map.TryGetValue(wireId, out var entry))
{
return afRequestId;
return entry.AfRequestId;
}
return wireId;
}
/// <summary>
/// Looks up the full approval entry for a given wire id, or <see langword="null"/>
/// when no mapping is present.
/// </summary>
public static ApprovalEntry? ResolveEntry(AgentSessionStateBag? stateBag, string wireId)
{
if (TryLoadMap(stateBag, out var map)
&& map.TryGetValue(wireId, out var entry))
{
return entry;
}
return null;
}
private static Dictionary<string, ApprovalEntry> LoadMap(AgentSessionStateBag stateBag)
=> TryLoadMap(stateBag, out var map) ? map : new Dictionary<string, ApprovalEntry>(StringComparer.Ordinal);
private static bool TryLoadMap(AgentSessionStateBag? stateBag, out Dictionary<string, ApprovalEntry> map)
{
if (stateBag is null)
{
map = null!;
return false;
}
// Don't swallow JsonException: ConvertMcpApprovalResponse fails fast on a missing entry,
// so an empty map here would just turn a clear deserialization error into a confusing one.
map = stateBag.GetValue<Dictionary<string, ApprovalEntry>>(StateBagKey)
?? new Dictionary<string, ApprovalEntry>(StringComparer.Ordinal);
return true;
}
}
@@ -1,113 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading.Tasks;
using OpenAI;
using OpenAI.Responses;
#pragma warning disable OPENAI001, SCME0001
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// A <see cref="ResponsesClient"/> subclass that delegates every protocol-level request to a
/// wrapped <see cref="ResponsesClient"/>. Before each call, a
/// <see cref="HostedAgentUserAgentPolicy"/> is added to the per-call
/// <see cref="RequestOptions"/> so the wrapped client's pipeline appends the hosted-agent
/// <c>User-Agent</c> segment on the wire.
/// </summary>
/// <remarks>
/// <para>
/// The streaming overloads MEAI binds via reflection (<c>internal CreateResponseStreamingAsync(CreateResponseOptions, RequestOptions)</c>
/// and <c>internal GetResponseStreamingAsync(GetResponseOptions, RequestOptions)</c>) bottom out
/// in calls to the public-virtual non-streaming protocol overloads on <see langword="this"/>. Overriding those
/// non-streaming overloads is therefore sufficient to intercept both streaming and non-streaming traffic.
/// </para>
/// <para>
/// The base pipeline supplied to <see cref="ResponsesClient(ClientPipeline, OpenAIClientOptions)"/>
/// is a dummy pipeline whose terminal transport throws if invoked. Every override on this class
/// delegates to the inner client BEFORE any code path reaches <see cref="ResponsesClient.Pipeline"/>, so the dummy is
/// never expected to run; the throwing transport surfaces any unexpected escape route loudly.
/// </para>
/// </remarks>
internal sealed class UserAgentResponsesClient : ResponsesClient
{
private readonly ResponsesClient _inner;
public UserAgentResponsesClient(ResponsesClient inner)
: base(BuildDummyPipeline(), new OpenAIClientOptions { Endpoint = inner?.Endpoint })
{
this._inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public override async Task<ClientResult> CreateResponseAsync(BinaryContent content, RequestOptions? options = null)
=> await this._inner.CreateResponseAsync(content, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult CreateResponse(BinaryContent content, RequestOptions? options = null)
=> this._inner.CreateResponse(content, AddUserAgentPolicy(options));
public override async Task<ClientResult> GetResponseAsync(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
=> await this._inner.GetResponseAsync(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult GetResponse(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
=> this._inner.GetResponse(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options));
public override async Task<ClientResult> DeleteResponseAsync(string responseId, RequestOptions options)
=> await this._inner.DeleteResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult DeleteResponse(string responseId, RequestOptions options)
=> this._inner.DeleteResponse(responseId, AddUserAgentPolicy(options));
public override async Task<ClientResult> CancelResponseAsync(string responseId, RequestOptions options)
=> await this._inner.CancelResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult CancelResponse(string responseId, RequestOptions options)
=> this._inner.CancelResponse(responseId, AddUserAgentPolicy(options));
public override async Task<ClientResult> GetInputTokenCountAsync(string contentType, BinaryContent content, RequestOptions? options = null)
=> await this._inner.GetInputTokenCountAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult GetInputTokenCount(string contentType, BinaryContent content, RequestOptions? options = null)
=> this._inner.GetInputTokenCount(contentType, content, AddUserAgentPolicy(options));
public override async Task<ClientResult> CompactResponseAsync(string contentType, BinaryContent content, RequestOptions? options = null)
=> await this._inner.CompactResponseAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult CompactResponse(string contentType, BinaryContent content, RequestOptions? options = null)
=> this._inner.CompactResponse(contentType, content, AddUserAgentPolicy(options));
public override async Task<ClientResult> GetResponseInputItemCollectionPageAsync(string responseId, int? limit, string order, string after, string before, RequestOptions options)
=> await this._inner.GetResponseInputItemCollectionPageAsync(responseId, limit, order, after, before, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult GetResponseInputItemCollectionPage(string responseId, int? limit, string order, string after, string before, RequestOptions options)
=> this._inner.GetResponseInputItemCollectionPage(responseId, limit, order, after, before, AddUserAgentPolicy(options));
private static RequestOptions AddUserAgentPolicy(RequestOptions? options)
{
options ??= new RequestOptions();
options.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall);
return options;
}
private static ClientPipeline BuildDummyPipeline()
{
var options = new ClientPipelineOptions
{
Transport = new ThrowingTransport(),
};
return ClientPipeline.Create(options, default, default, default);
}
private sealed class ThrowingTransport : PipelineTransport
{
private const string Message =
"UserAgentResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of UserAgentResponsesClient.";
protected override PipelineMessage CreateMessageCore() => throw new InvalidOperationException(Message);
protected override void ProcessCore(PipelineMessage message) => throw new InvalidOperationException(Message);
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new InvalidOperationException(Message);
}
}
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Delegating <see cref="AIAgent"/> that captures any <c>x-client-*</c> headers stored on
/// <see cref="ChatClientAgentRunOptions.ChatOptions"/> by callers of
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/> and pushes
/// them onto a <see cref="ClientHeadersScope"/> for the lifetime of the run. The scope is read by
/// <see cref="ClientHeadersPolicy"/> inside the SCM transport pipeline and stamped onto the
/// outbound request.
/// </summary>
/// <remarks>
/// <para>
/// The decorator snapshots the header dictionary at scope-push time so concurrent runs that share
/// the same <see cref="ChatOptions"/> reference are isolated; mutating the source dictionary after
/// <c>RunAsync</c> begins does not leak into in-flight requests.
/// </para>
/// <para>
/// Streaming uses the async-iterator pattern so the AsyncLocal scope stays alive across yields,
/// which is required because the underlying HTTP send happens during enumeration.
/// </para>
/// </remarks>
internal sealed class ClientHeadersAgent : DelegatingAIAgent
{
public ClientHeadersAgent(AIAgent innerAgent)
: base(innerAgent)
{
}
/// <inheritdoc/>
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
var snapshot = TrySnapshot(options);
if (snapshot is null)
{
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
}
return RunAsyncCoreAsync(messages, session, options, snapshot, cancellationToken);
async Task<AgentResponse> RunAsyncCoreAsync(
IEnumerable<ChatMessage> innerMessages,
AgentSession? innerSession,
AgentRunOptions? innerOptions,
Dictionary<string, string> innerSnapshot,
CancellationToken innerCt)
{
using var _ = ClientHeadersScope.Push(innerSnapshot);
return await this.InnerAgent.RunAsync(innerMessages, innerSession, innerOptions, innerCt).ConfigureAwait(false);
}
}
/// <inheritdoc/>
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var snapshot = TrySnapshot(options);
using var _ = snapshot is null ? default : ClientHeadersScope.Push(snapshot);
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
/// <summary>Reads the header dictionary stamped by <c>WithClientHeader(s)</c> and returns an immutable snapshot, or <see langword="null"/> if none.</summary>
private static Dictionary<string, string>? TrySnapshot(AgentRunOptions? options)
{
if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions })
{
return null;
}
var headers = chatOptions.GetClientHeaders();
if (headers is null || headers.Count == 0)
{
return null;
}
// Copy to defeat caller mutation after RunAsync starts.
var copy = new Dictionary<string, string>(headers.Count, System.StringComparer.OrdinalIgnoreCase);
foreach (var kvp in headers)
{
copy[kvp.Key] = kvp.Value;
}
return copy;
}
}
@@ -0,0 +1,204 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Provides extension methods for attaching per-call <c>x-client-*</c> headers to an agent run
/// and for opting an existing <see cref="AIAgent"/> into the client-headers pipeline.
/// </summary>
/// <remarks>
/// <para>
/// The Foundry platform forwards headers prefixed with <c>x-client-</c> transparently from the
/// Agent Endpoint into the agent container (see the multi-tenant overlay design). Callers use
/// <see cref="WithClientHeader(ChatOptions, string, string)"/> or
/// <see cref="WithClientHeaders(ChatOptions, IEnumerable{KeyValuePair{string, string}})"/> to
/// stamp headers per <c>RunAsync</c> call (for example to attest the SaaS end-user identity
/// in <c>x-client-end-user-id</c>).
/// </para>
/// <para>
/// Headers are only delivered to the wire when:
/// <list type="number">
/// <item><description>the agent has been wrapped with <see cref="UseClientHeaders(AIAgentBuilder)"/> (or built via a Foundry factory that pre-wires it), and</description></item>
/// <item><description>the underlying <see cref="IChatClient"/> exposes the experimental MEAI 10.5.1 <see cref="OpenAIRequestPolicies"/> service (true for OpenAI-backed clients).</description></item>
/// </list>
/// When either condition is not met the call is a silent no-op.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
public static class ClientHeadersExtensions
{
/// <summary>The well-known <see cref="ChatOptions.AdditionalProperties"/> key used to carry the dictionary across packages.</summary>
internal const string ClientHeadersKey = "Microsoft.Agents.AI.Foundry.ClientHeaders";
/// <summary>The required prefix on every client header name (case-insensitive).</summary>
private const string ClientHeaderPrefix = "x-client-";
/// <summary>
/// Adds a single <c>x-client-*</c> header to the per-call carrier on <paramref name="options"/>.
/// </summary>
/// <param name="options">The <see cref="ChatOptions"/> instance to mutate.</param>
/// <param name="name">The header name. Must start with <c>x-client-</c> (case-insensitive).</param>
/// <param name="value">The header value. Must be non-empty.</param>
/// <returns><paramref name="options"/> for fluent chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="options"/>, <paramref name="name"/>, or <paramref name="value"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> does not start with <c>x-client-</c>, or is empty/whitespace, or <paramref name="value"/> is empty.</exception>
/// <exception cref="InvalidOperationException">The carrier slot on <see cref="ChatOptions.AdditionalProperties"/> is occupied by a value of a foreign type.</exception>
public static ChatOptions WithClientHeader(this ChatOptions options, string name, string value)
{
_ = Throw.IfNull(options);
ValidateHeader(name, value);
var dict = GetOrCreateHeadersDictionary(options);
dict[name] = value;
return options;
}
/// <summary>
/// Adds multiple <c>x-client-*</c> headers to the per-call carrier on <paramref name="options"/>.
/// </summary>
/// <remarks>Validation is all-or-nothing: if any entry is invalid no entries are written.</remarks>
/// <param name="options">The <see cref="ChatOptions"/> instance to mutate.</param>
/// <param name="headers">The headers to add. Each name must start with <c>x-client-</c>.</param>
/// <returns><paramref name="options"/> for fluent chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="options"/> or <paramref name="headers"/> is <see langword="null"/>, or any element of <paramref name="headers"/> has a <see langword="null"/> name or value.</exception>
/// <exception cref="ArgumentException">Any header name does not start with <c>x-client-</c>, or any name is empty/whitespace, or any value is empty.</exception>
/// <exception cref="InvalidOperationException">The carrier slot on <see cref="ChatOptions.AdditionalProperties"/> is occupied by a value of a foreign type.</exception>
public static ChatOptions WithClientHeaders(this ChatOptions options, IEnumerable<KeyValuePair<string, string>> headers)
{
_ = Throw.IfNull(options);
_ = Throw.IfNull(headers);
// Validate first; mutate only when every entry passes.
var staged = new List<KeyValuePair<string, string>>();
foreach (var kvp in headers)
{
ValidateHeader(kvp.Key, kvp.Value);
staged.Add(kvp);
}
if (staged.Count == 0)
{
return options;
}
var dict = GetOrCreateHeadersDictionary(options);
foreach (var kvp in staged)
{
dict[kvp.Key] = kvp.Value;
}
return options;
}
/// <summary>
/// Wraps the agent built by <paramref name="builder"/> so that headers stamped by
/// <see cref="WithClientHeader(ChatOptions, string, string)"/> on the per-call
/// <see cref="ChatOptions"/> are forwarded onto the outbound HTTP request.
/// </summary>
/// <remarks>
/// <para>
/// Idempotent: if the inner agent is already wrapped with a <see cref="ClientHeadersAgent"/>
/// anywhere in its delegating chain, the agent is returned unchanged. This makes
/// <c>myFoundryAgent.AsBuilder().UseClientHeaders().Build()</c> safe even though Foundry
/// agents are pre-wired automatically.
/// </para>
/// <para>
/// Also registers <see cref="ClientHeadersPolicy"/> against the underlying chat client's
/// <see cref="OpenAIRequestPolicies"/> service if available. When the underlying chat client
/// is not OpenAI-backed (the service lookup returns <see langword="null"/>), the registration
/// step is silently skipped; the agent decorator still runs but no headers are stamped on
/// the wire. See the type-level remarks for the conditions under which delivery happens.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to extend.</param>
/// <returns>The same builder, to allow fluent chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
public static AIAgentBuilder UseClientHeaders(this AIAgentBuilder builder) =>
Throw.IfNull(builder).Use((AIAgent innerAgent, IServiceProvider services) =>
{
// Agent-side dedup: if any decorator in the chain is already a ClientHeadersAgent, no-op.
if (innerAgent.GetService<ClientHeadersAgent>() is not null)
{
return innerAgent;
}
// Best-effort policy registration on the underlying OpenAI-backed chat client.
// Silent no-op when the service is unavailable (non-OpenAI providers).
if (innerAgent.GetService<OpenAIRequestPolicies>() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ClientHeadersPolicy.Instance,
System.ClientModel.Primitives.PipelinePosition.PerCall);
}
return new ClientHeadersAgent(innerAgent);
});
/// <summary>Reads the headers dictionary stamped by callers, or <see langword="null"/> if none.</summary>
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Internal helper.")]
internal static IReadOnlyDictionary<string, string>? GetClientHeaders(this ChatOptions options)
{
if (options.AdditionalProperties is null)
{
return null;
}
if (!options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var raw))
{
return null;
}
return raw as Dictionary<string, string>;
}
private static Dictionary<string, string> GetOrCreateHeadersDictionary(ChatOptions options)
{
options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
if (options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var existing))
{
if (existing is Dictionary<string, string> dict)
{
return dict;
}
throw new InvalidOperationException(
$"ChatOptions.AdditionalProperties[\"{ClientHeadersKey}\"] is occupied by a value of type '{existing?.GetType().FullName ?? "null"}', expected Dictionary<string, string>.");
}
var fresh = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
options.AdditionalProperties[ClientHeadersKey] = fresh;
return fresh;
}
private static void ValidateHeader(string name, string value)
{
_ = Throw.IfNull(name);
_ = Throw.IfNull(value);
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Header name must not be empty or whitespace.", nameof(name));
}
if (value.Length == 0)
{
throw new ArgumentException("Header value must not be empty.", nameof(value));
}
if (!name.StartsWith(ClientHeaderPrefix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"Header name '{name}' must start with '{ClientHeaderPrefix}' (case-insensitive). Only x-client-* headers are forwarded by the Foundry platform.",
nameof(name));
}
}
}
@@ -0,0 +1,152 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Pipeline policy that stamps <c>x-client-*</c> headers from the current
/// <see cref="ClientHeadersScope"/> onto outbound OpenAI Responses requests.
/// </summary>
/// <remarks>
/// <para>
/// Registered once per <see cref="OpenAIRequestPolicies"/> instance via the new MEAI 10.5.1
/// extension hook. Headers are written using <see cref="PipelineRequestHeaders.Set(string, string)"/>
/// so per-call values overwrite anything stamped earlier in the pipeline (for example by static
/// pipeline policies registered on the underlying client). This also makes accidental double
/// registration value-stable.
/// </para>
/// </remarks>
internal sealed class ClientHeadersPolicy : PipelinePolicy
{
public static ClientHeadersPolicy Instance { get; } = new ClientHeadersPolicy();
private ClientHeadersPolicy()
{
}
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
Stamp(message);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
Stamp(message);
return ProcessNextAsync(message, pipeline, currentIndex);
}
private static void Stamp(PipelineMessage message)
{
var headers = ClientHeadersScope.Current;
if (headers is null || headers.Count == 0)
{
return;
}
foreach (var kvp in headers)
{
// Per-call wins: Set overwrites any same-name header previously stamped by other policies.
message.Request.Headers.Set(kvp.Key, kvp.Value);
}
}
}
/// <summary>
/// Best-effort reflection helpers for <see cref="OpenAIRequestPolicies"/>. MEAI 10.5.1 does not
/// publicly expose its registered-policies list, so we reach into the private <c>_entries</c>
/// field to detect duplicate registrations of <see cref="ClientHeadersPolicy.Instance"/>.
/// </summary>
/// <remarks>
/// All access is guarded with try/catch and graceful fallback. If MEAI changes the field name
/// or shape in a future bump, dedup degrades to "always add" but stamping stays correct because
/// <see cref="ClientHeadersPolicy"/> uses <c>Headers.Set</c>. A CI test asserts the field shape
/// to fail loudly on future MEAI bumps.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
internal static class OpenAIRequestPoliciesReflection
{
private static readonly Lazy<FieldInfo?> s_entriesField = new(() =>
{
try
{
return typeof(OpenAIRequestPolicies).GetField(
"_entries",
BindingFlags.Instance | BindingFlags.NonPublic);
}
catch
{
return null;
}
});
/// <summary>Returns <see langword="true"/> if <paramref name="policies"/> already contains <paramref name="policy"/>.</summary>
/// <remarks>Returns <see langword="false"/> on any reflection failure (caller should treat the registration as not yet done).</remarks>
#if NET
[UnconditionalSuppressMessage("Trimming", "IL2075:RequiresUnreferencedCode",
Justification = "Reflecting on the private Entry struct shipped by Microsoft.Extensions.AI.OpenAI; falls back gracefully if shape changes. CI test asserts the field shape on every MEAI bump.")]
#endif
public static bool ContainsPolicy(OpenAIRequestPolicies policies, PipelinePolicy policy)
{
try
{
if (s_entriesField.Value?.GetValue(policies) is not Array entries)
{
return false;
}
for (int i = 0; i < entries.Length; i++)
{
var entry = entries.GetValue(i);
if (entry is null)
{
continue;
}
// Entry is a private struct with a Policy property/field. Try property first, then field.
var entryType = entry.GetType();
var policyMember = entryType.GetProperty("Policy", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
object? value = policyMember is not null
? policyMember.GetValue(entry)
: entryType.GetField("Policy", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(entry);
if (ReferenceEquals(value, policy))
{
return true;
}
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Registers <paramref name="policy"/> on <paramref name="policies"/> if not already present.
/// </summary>
/// <returns>
/// <see langword="true"/> if <c>AddPolicy</c> was called on this invocation; <see langword="false"/>
/// when the policy was already detected as present and the call was skipped.
/// </returns>
public static bool AddPolicyIfMissing(OpenAIRequestPolicies policies, PipelinePolicy policy, PipelinePosition position = PipelinePosition.PerCall)
{
if (ContainsPolicy(policies, policy))
{
return false;
}
policies.AddPolicy(policy, position);
return true;
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// AsyncLocal carrier that bridges per-call client-header values from the
/// <see cref="ClientHeadersAgent"/> decorator down to the
/// <see cref="ClientHeadersPolicy"/> running inside the SCM transport pipeline.
/// </summary>
/// <remarks>
/// AsyncLocal flows the value into downstream awaits but does not roll the value back when the
/// setting method returns. This type pairs each <see cref="Push(IReadOnlyDictionary{string, string}?)"/>
/// with a disposable that explicitly restores the prior value, giving stack-style LIFO semantics
/// for nested or sequential per-call scopes on the same async flow.
/// </remarks>
internal static class ClientHeadersScope
{
private static readonly AsyncLocal<IReadOnlyDictionary<string, string>?> s_current = new();
/// <summary>Gets the dictionary captured by the most recent <see cref="Push(IReadOnlyDictionary{string, string}?)"/> on this async flow.</summary>
public static IReadOnlyDictionary<string, string>? Current => s_current.Value;
/// <summary>
/// Pushes a new value as the current scope. Disposing the returned token restores the previous value.
/// </summary>
/// <param name="headers">The header dictionary to surface to the policy. May be <see langword="null"/>.</param>
public static Scope Push(IReadOnlyDictionary<string, string>? headers)
{
var previous = s_current.Value;
s_current.Value = headers;
return new Scope(previous);
}
/// <summary>Disposable token that restores the previous scope on <see cref="Dispose"/>.</summary>
internal readonly struct Scope : System.IDisposable
{
private readonly IReadOnlyDictionary<string, string>? _previous;
internal Scope(IReadOnlyDictionary<string, string>? previous)
{
this._previous = previous;
}
public void Dispose() => s_current.Value = this._previous;
}
}
@@ -102,7 +102,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have an <see cref="AIProjectClient"/> and a configured <see cref="ChatClientAgent"/>.
/// </summary>
internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent)
: base(Throw.IfNull(innerAgent))
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
}
@@ -128,7 +128,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// </para>
/// </remarks>
public ValueTask<AgentSession> CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default)
=> ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversationId, cancellationToken);
=> this.GetInnerChatClientAgent().CreateSessionAsync(conversationId, cancellationToken);
/// <summary>
/// Creates a server-side conversation session that appears in the Foundry Project UI.
@@ -143,9 +143,14 @@ public sealed class FoundryAgent : DelegatingAIAgent
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
return (ChatClientAgentSession)await ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversation.Id, cancellationToken).ConfigureAwait(false);
return (ChatClientAgentSession)await this.GetInnerChatClientAgent().CreateSessionAsync(conversation.Id, cancellationToken).ConfigureAwait(false);
}
/// <summary>Walks the delegating chain to find the inner <see cref="ChatClientAgent"/>.</summary>
private ChatClientAgent GetInnerChatClientAgent() =>
this.GetService<ChatClientAgent>()
?? throw new InvalidOperationException("FoundryAgent inner chain does not contain a ChatClientAgent.");
#endregion
/// <inheritdoc/>
@@ -161,7 +166,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
#region Private helpers
private static ChatClientAgent CreateInnerAgent(
private static AIAgent CreateInnerAgent(
AIProjectClient aiProjectClient,
string model, string instructions,
string? name, string? description,
@@ -191,7 +196,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services);
}
private static ChatClientAgent CreateResponsesChatClientAgent(
private static AIAgent CreateResponsesChatClientAgent(
AIProjectClient aiProjectClient,
ChatClientAgentOptions agentOptions,
Func<IChatClient, IChatClient>? clientFactory,
@@ -210,10 +215,36 @@ public sealed class FoundryAgent : DelegatingAIAgent
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services);
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, loggerFactory, services));
}
private static ChatClientAgent CreateInnerAgentFromEndpoint(
/// <summary>
/// Registers <see cref="ClientHeadersPolicy"/> on the agent's underlying chat client (if it
/// exposes <see cref="OpenAIRequestPolicies"/>) and wraps the agent in a
/// <see cref="ClientHeadersAgent"/> so per-call <c>x-client-*</c> headers stamped via
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/> reach
/// the wire. Idempotent: if the chain already contains a <see cref="ClientHeadersAgent"/>,
/// the original instance is returned unchanged.
/// </summary>
private static AIAgent WireClientHeaders(ChatClientAgent innerAgent)
{
if (innerAgent.GetService<ClientHeadersAgent>() is not null)
{
return innerAgent;
}
if (innerAgent.ChatClient.GetService<OpenAIRequestPolicies>() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ClientHeadersPolicy.Instance,
System.ClientModel.Primitives.PipelinePosition.PerCall);
}
return new ClientHeadersAgent(innerAgent);
}
private static AIAgent CreateInnerAgentFromEndpoint(
AIProjectClient aiProjectClient,
Uri agentEndpoint,
IList<AITool>? tools,
@@ -238,7 +269,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, agentOptions, services: services);
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
}
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
@@ -210,7 +210,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
string prompt = string.Join("\n", messages.Select(m => m.Text));
// Handle DataContent as attachments
(List<UserMessageDataAttachmentsItem>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
(List<UserMessageAttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
messages,
cancellationToken).ConfigureAwait(false);
@@ -443,11 +443,11 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
}
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
private static async Task<(List<UserMessageAttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken)
{
List<UserMessageDataAttachmentsItem>? attachments = null;
List<UserMessageAttachmentFile>? attachments = null;
string? tempDir = null;
foreach (ChatMessage message in messages)
{
@@ -461,7 +461,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
attachments ??= [];
attachments.Add(new UserMessageDataAttachmentsItemFile
attachments.Add(new UserMessageAttachmentFile
{
Path = tempFilePath,
DisplayName = Path.GetFileName(tempFilePath)
@@ -43,10 +43,11 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false);
InitializablePropertyPath variable = Throw.IfNull(this.Model.Variable);
bool isValueUndefined = context.ReadState(variable.Path) is BlankValue;
// Snapshot prior-execution state before we mutate it below so the SkipQuestionMode
// evaluation reflects whether this is the first time the action has run.
bool hasExecutedPreviously = await this._hasExecuted.ReadAsync(context).ConfigureAwait(false);
bool proceed = this.Evaluator.GetValue(this.Model.AlwaysPrompt).Value;
if (!proceed)
@@ -55,16 +56,23 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
proceed =
mode switch
{
SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => isValueUndefined && !await this._hasExecuted.ReadAsync(context).ConfigureAwait(false),
SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => isValueUndefined || hasExecutedPreviously,
SkipQuestionMode.AlwaysSkipIfVariableHasValue => isValueUndefined,
SkipQuestionMode.AlwaysAsk => true,
_ => true,
};
}
// Record that the action has executed in the same executor scope as the read above.
// (CaptureResponseAsync runs in a different executor's state scope, so writing it there
// would not be visible to subsequent ExecuteAsync invocations triggered by GotoAction.)
await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false);
if (proceed)
{
await this.PromptAsync(context, cancellationToken).ConfigureAwait(false);
// Initial prompt: count is 0 because no responses have been received yet for this turn.
// _promptCount itself is tracked in CaptureResponseAsync's scope (see comment on _promptCount).
await this.PromptAsync(context, actualCount: 0, cancellationToken).ConfigureAwait(false);
}
else
{
@@ -76,14 +84,18 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
public async ValueTask PrepareResponseAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
int count = await this._promptCount.ReadAsync(context).ConfigureAwait(false);
ExternalInputRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt));
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
await this._promptCount.WriteAsync(context, count + 1).ConfigureAwait(false);
}
public async ValueTask CaptureResponseAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
{
// _promptCount is tracked in this (Capture) executor's scope so reads and writes are coherent.
// Each Capture invocation represents an attempt to satisfy the question; increment up front
// and pass the value to PromptAsync explicitly so the retry/default decision is scope-independent.
int promptCount = await this._promptCount.ReadAsync(context).ConfigureAwait(false) + 1;
await this._promptCount.WriteAsync(context, promptCount).ConfigureAwait(false);
FormulaValue? extractedValue = null;
if (!response.HasMessages)
{
@@ -106,10 +118,12 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
if (extractedValue is null)
{
await this.PromptAsync(context, cancellationToken).ConfigureAwait(false);
await this.PromptAsync(context, promptCount, cancellationToken).ConfigureAwait(false);
}
else
{
// Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh.
await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false);
bool autoSend = true;
if (this.Model.ExtensionData?.Properties.TryGetValue("autoSend", out DataValue? autoSendValue) ?? false)
@@ -133,7 +147,6 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
}
await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, extractedValue, context).ConfigureAwait(false);
await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
}
@@ -143,10 +156,9 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
private async ValueTask PromptAsync(IWorkflowContext context, CancellationToken cancellationToken)
private async ValueTask PromptAsync(IWorkflowContext context, int actualCount, CancellationToken cancellationToken)
{
long repeatCount = this.Evaluator.GetValue(this.Model.RepeatCount).Value;
int actualCount = await this._promptCount.ReadAsync(context).ConfigureAwait(false);
if (actualCount >= repeatCount)
{
DataValue defaultValue = DataValue.Blank();
@@ -158,6 +170,8 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.ToFormula(), context).ConfigureAwait(false);
string defaultValueResponse = this.FormatPrompt(this.Model.DefaultValueResponse);
await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim()), cancellationToken).ConfigureAwait(false);
// Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh.
await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
else
@@ -181,8 +181,16 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
AgentResponse response = await this.InvokeAgentAsync(filteredMessages, context, emitEvents, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(response.Messages is List<ChatMessage> list ? list : response.Messages.ToList(), cancellationToken)
.ConfigureAwait(false);
// Filter out server-side artifacts (reasoning tokens, web search calls, etc.)
// that are internal to this agent. Forwarding them to other agents in the workflow
// causes invalid request errors when the receiving agent uses the Responses API,
// because these item types are not valid as input items.
List<ChatMessage> forwardableMessages = FilterForwardableMessages(response.Messages).ToList();
if (forwardableMessages.Count > 0)
{
await context.SendMessageAsync(forwardableMessages, cancellationToken)
.ConfigureAwait(false);
}
// If we have no outstanding requests, we can yield a turn token back to the workflow.
if (!this.HasOutstandingRequests)
@@ -241,4 +249,60 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
return response;
}
/// <summary>
/// Content types that represent meaningful conversational content portable across agents.
/// Messages containing only content types not in this set (e.g. reasoning tokens, web search
/// calls) are filtered out before forwarding, as they are output-only items that cause
/// schema validation errors when sent as input to the Responses API.
/// </summary>
private static readonly HashSet<Type> s_forwardableContentTypes =
[
typeof(TextContent),
typeof(DataContent),
typeof(UriContent),
typeof(FunctionCallContent),
typeof(FunctionResultContent),
typeof(ToolApprovalRequestContent),
typeof(ToolApprovalResponseContent),
typeof(HostedFileContent),
typeof(ErrorContent),
];
/// <summary>
/// Filters response messages to only include those with portable conversational content,
/// and strips <see cref="ChatMessage.RawRepresentation"/> so that provider-specific output
/// items (e.g. <c>mcp_list_tools</c>, <c>reasoning</c>, <c>fabric_dataagent_preview_call</c>)
/// are not round-tripped by the M.E.AI library when the messages are sent to another agent.
/// </summary>
private static List<ChatMessage> FilterForwardableMessages(IList<ChatMessage> messages)
{
List<ChatMessage> result = [];
foreach (ChatMessage message in messages)
{
// Extract only the content items that are portable across agents.
List<AIContent> forwardableContents = message.Contents
.Where(c => s_forwardableContentTypes.Any(t => t.IsAssignableFrom(c.GetType())))
.ToList();
if (forwardableContents.Count == 0)
{
continue;
}
// Build a clean message without the provider-specific RawRepresentation,
// which would otherwise cause the M.E.AI library to round-trip the original
// output-only items (e.g. mcp_list_tools) as input to the next agent.
result.Add(new ChatMessage(message.Role, forwardableContents)
{
AuthorName = message.AuthorName,
MessageId = message.MessageId,
CreatedAt = message.CreatedAt,
AdditionalProperties = message.AdditionalProperties is null ? null : new(message.AdditionalProperties),
});
}
return result;
}
}
@@ -36,7 +36,7 @@ internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(Execu
sharedState.PreviousAgentId = handoff.PreviousAgentId;
}
await context.YieldOutputAsync(sharedState.Conversation.CloneAllMessages(), cancellationToken).ConfigureAwait(false);
await context.YieldOutputAsync(sharedState.Conversation.CloneHistory(), cancellationToken).ConfigureAwait(false);
return sharedState;
}, context, cancellationToken).ConfigureAwait(false);
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -23,7 +24,20 @@ internal static class HandoffConstants
internal sealed class HandoffSharedState
{
public MultiPartyConversation Conversation { get; } = new();
[JsonConstructor]
internal HandoffSharedState(MultiPartyConversation conversation, string? previousAgentId)
{
this.Conversation = conversation;
this.PreviousAgentId = previousAgentId;
}
public HandoffSharedState()
{
this.Conversation = new([]);
}
[JsonInclude]
public MultiPartyConversation Conversation { get; internal set; }
public string? PreviousAgentId { get; set; }
}
@@ -3,20 +3,33 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class MultiPartyConversation
{
private readonly List<ChatMessage> _history = [];
private readonly object _mutex = new();
public List<ChatMessage> CloneAllMessages()
[JsonConstructor]
internal MultiPartyConversation(List<ChatMessage> history)
{
this.History = history ?? [];
}
/// <summary>
/// In order to support JSON serializaiton, this property must be internally visible. However, it should not be used
/// in concurrent contexts without proper locking, as the underlying list is not thread safe.
/// </summary>
[JsonInclude]
internal List<ChatMessage> History { get; }
public List<ChatMessage> CloneHistory()
{
lock (this._mutex)
{
return this._history.ToList();
return this.History.ToList();
}
}
@@ -24,23 +37,24 @@ internal sealed class MultiPartyConversation
{
lock (this._mutex)
{
int count = this._history.Count - bookmark;
int count = this.History.Count - bookmark;
if (count < 0)
{
throw new InvalidOperationException($"Bookmark value too large: {bookmark} vs count={count}");
}
return (this._history.Skip(bookmark).ToArray(), this.CurrentBookmark);
return (this.History.Skip(bookmark).ToArray(), this.CurrentBookmark);
}
}
private int CurrentBookmark => this._history.Count;
[JsonIgnore]
private int CurrentBookmark => this.History.Count;
public int AddMessages(IEnumerable<ChatMessage> messages)
{
lock (this._mutex)
{
this._history.AddRange(messages);
this.History.AddRange(messages);
return this.CurrentBookmark;
}
}
@@ -49,7 +63,7 @@ internal sealed class MultiPartyConversation
{
lock (this._mutex)
{
this._history.Add(message);
this.History.Add(message);
return this.CurrentBookmark;
}
}
@@ -95,6 +95,8 @@ internal static partial class WorkflowsJsonUtilities
// Built-in Executor State Types
[JsonSerializable(typeof(AIAgentHostState))]
[JsonSerializable(typeof(HandoffSharedState))]
[JsonSerializable(typeof(HandoffAgentHostState))]
// Event Types
//[JsonSerializable(typeof(WorkflowEvent))]
@@ -1,8 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -30,9 +33,13 @@ namespace Microsoft.Agents.AI;
/// <item><description><c>TodoList_GetAll</c> — Retrieve all todo items (complete and incomplete).</description></item>
/// </list>
/// </para>
/// <para>
/// All operations are thread-safe; concurrent reads and mutations on the same session are serialized
/// using a per-session lock to prevent duplicate IDs, lost updates, or inconsistent reads.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class TodoProvider : AIContextProvider
public sealed class TodoProvider : AIContextProvider, IDisposable
{
private const string DefaultInstructions =
"""
@@ -55,6 +62,10 @@ public sealed class TodoProvider : AIContextProvider
private readonly ProviderSessionState<TodoState> _sessionState;
private readonly string _instructions;
private readonly bool _suppressTodoListMessage;
private readonly Func<IReadOnlyList<TodoItem>, string>? _todoListMessageBuilder;
private readonly ConditionalWeakTable<AgentSession, SemaphoreSlim> _sessionLocks = new();
private readonly SemaphoreSlim _nullSessionLock = new(1, 1);
private IReadOnlyList<string>? _stateKeys;
/// <summary>
@@ -64,6 +75,8 @@ public sealed class TodoProvider : AIContextProvider
public TodoProvider(TodoProviderOptions? options = null)
{
this._instructions = options?.Instructions ?? DefaultInstructions;
this._suppressTodoListMessage = options?.SuppressTodoListMessage ?? false;
this._todoListMessageBuilder = options?.TodoListMessageBuilder;
this._sessionState = new ProviderSessionState<TodoState>(
_ => new TodoState(),
this.GetType().Name,
@@ -73,64 +86,146 @@ public sealed class TodoProvider : AIContextProvider
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <inheritdoc />
public void Dispose()
{
this._nullSessionLock.Dispose();
}
/// <summary>
/// Gets all todo items from the session state.
/// </summary>
/// <remarks>
/// The returned <see cref="TodoItem"/> instances are the live objects from internal state.
/// Modifying their properties will mutate the provider's state directly.
/// </remarks>
/// <param name="session">The agent session to read todos from.</param>
/// <returns>A read-only list of all todo items.</returns>
public IReadOnlyList<TodoItem> GetAllTodos(AgentSession? session)
/// <returns>A list of all todo items. The items are live references to internal state.</returns>
public async Task<IReadOnlyList<TodoItem>> GetAllTodosAsync(AgentSession? session)
{
return this._sessionState.GetOrInitializeState(session).Items;
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
return state.Items.ToList();
}
finally
{
sessionLock.Release();
}
}
/// <summary>
/// Gets the remaining (incomplete) todo items from the session state.
/// </summary>
/// <remarks>
/// The returned <see cref="TodoItem"/> instances are the live objects from internal state.
/// Modifying their properties will mutate the provider's state directly.
/// </remarks>
/// <param name="session">The agent session to read todos from.</param>
/// <returns>A list of incomplete todo items.</returns>
public List<TodoItem> GetRemainingTodos(AgentSession? session)
/// <returns>A list of incomplete todo items. The items are live references to internal state.</returns>
public async Task<List<TodoItem>> GetRemainingTodosAsync(AgentSession? session)
{
return this._sessionState.GetOrInitializeState(session).Items.Where(t => !t.IsComplete).ToList();
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
return state.Items.Where(t => !t.IsComplete).ToList();
}
finally
{
sessionLock.Release();
}
}
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
TodoState state = this._sessionState.GetOrInitializeState(context.Session);
return new ValueTask<AIContext>(new AIContext
var aiContext = new AIContext
{
Instructions = this._instructions,
Tools = this.CreateTools(state, context.Session),
});
Tools = this.CreateTools(context.Session),
};
if (!this._suppressTodoListMessage)
{
// Inject a synthetic user message summarizing the current todo list so the agent
// is aware of outstanding work at the start of each invocation.
SemaphoreSlim sessionLock = this.GetSessionLock(context.Session);
await sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
List<TodoItem> currentItems;
try
{
TodoState state = this._sessionState.GetOrInitializeState(context.Session);
currentItems = state.Items.ToList();
}
finally
{
sessionLock.Release();
}
string message = this._todoListMessageBuilder is not null
? this._todoListMessageBuilder(currentItems)
: FormatTodoListMessage(currentItems);
aiContext.Messages =
[
new ChatMessage(ChatRole.User, message),
];
}
return aiContext;
}
// Note: These tool delegates mutate shared session state without synchronization.
// This is safe because FunctionInvokingChatClient serializes tool calls within a single run.
private AITool[] CreateTools(TodoState state, AgentSession? session)
/// <summary>
/// Returns the per-session semaphore used to serialize all todo operations.
/// </summary>
private SemaphoreSlim GetSessionLock(AgentSession? session)
{
if (session is null)
{
return this._nullSessionLock;
}
return this._sessionLocks.GetValue(session, _ => new SemaphoreSlim(1, 1));
}
private AITool[] CreateTools(AgentSession? session)
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;
return
[
AIFunctionFactory.Create(
(List<TodoItemInput> todos) =>
async (List<TodoItemInput> todos) =>
{
var created = new List<TodoItem>();
foreach (var input in todos)
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
var item = new TodoItem
TodoState state = this._sessionState.GetOrInitializeState(session);
var created = new List<TodoItem>();
foreach (var input in todos)
{
Id = state.NextId++,
Title = input.Title,
Description = input.Description,
};
state.Items.Add(item);
created.Add(item);
}
var item = new TodoItem
{
Id = state.NextId++,
Title = input.Title.Trim(),
Description = input.Description?.Trim(),
};
state.Items.Add(item);
created.Add(item);
}
this._sessionState.SaveState(session, state);
return created;
this._sessionState.SaveState(session, state);
return created;
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
@@ -140,25 +235,35 @@ public sealed class TodoProvider : AIContextProvider
}),
AIFunctionFactory.Create(
(List<int> ids) =>
async (List<int> ids) =>
{
var idSet = new HashSet<int>(ids);
int completed = 0;
foreach (TodoItem item in state.Items)
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
if (!item.IsComplete && idSet.Contains(item.Id))
TodoState state = this._sessionState.GetOrInitializeState(session);
var idSet = new HashSet<int>(ids);
int completed = 0;
foreach (TodoItem item in state.Items)
{
item.IsComplete = true;
completed++;
if (!item.IsComplete && idSet.Contains(item.Id))
{
item.IsComplete = true;
completed++;
}
}
}
if (completed > 0)
if (completed > 0)
{
this._sessionState.SaveState(session, state);
}
return completed;
}
finally
{
this._sessionState.SaveState(session, state);
sessionLock.Release();
}
return completed;
},
new AIFunctionFactoryOptions
{
@@ -168,17 +273,27 @@ public sealed class TodoProvider : AIContextProvider
}),
AIFunctionFactory.Create(
(List<int> ids) =>
async (List<int> ids) =>
{
var idSet = new HashSet<int>(ids);
int removed = state.Items.RemoveAll(t => idSet.Contains(t.Id));
if (removed > 0)
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
this._sessionState.SaveState(session, state);
}
TodoState state = this._sessionState.GetOrInitializeState(session);
var idSet = new HashSet<int>(ids);
int removed = state.Items.RemoveAll(t => idSet.Contains(t.Id));
return removed;
if (removed > 0)
{
this._sessionState.SaveState(session, state);
}
return removed;
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
@@ -188,7 +303,20 @@ public sealed class TodoProvider : AIContextProvider
}),
AIFunctionFactory.Create(
() => state.Items.Where(t => !t.IsComplete).ToList(),
async () =>
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
return state.Items.Where(t => !t.IsComplete).ToList();
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
Name = "TodoList_GetRemaining",
@@ -197,7 +325,20 @@ public sealed class TodoProvider : AIContextProvider
}),
AIFunctionFactory.Create(
() => state.Items,
async () =>
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
TodoState state = this._sessionState.GetOrInitializeState(session);
return state.Items.ToList();
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
Name = "TodoList_GetAll",
@@ -206,4 +347,27 @@ public sealed class TodoProvider : AIContextProvider
}),
];
}
internal static string FormatTodoListMessage(List<TodoItem> items)
{
if (items.Count == 0)
{
return "### Current todo list\n- none yet";
}
var sb = new StringBuilder("### Current todo list\n");
foreach (var item in items)
{
string status = item.IsComplete ? "done" : "open";
sb.Append($"- {item.Id} [{status}] {item.Title}");
if (!string.IsNullOrWhiteSpace(item.Description))
{
sb.Append($": {item.Description}");
}
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
}
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
@@ -19,4 +21,24 @@ public sealed class TodoProviderOptions
/// that guide the agent on how to manage todos effectively.
/// </value>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to suppress injecting the todo list message
/// into the conversation context.
/// </summary>
/// <value>
/// When <see langword="false"/> (the default), a synthetic user message summarizing the current
/// todo list is injected at each invocation. When <see langword="true"/>, no message is injected.
/// </value>
public bool SuppressTodoListMessage { get; set; }
/// <summary>
/// Gets or sets a custom function that builds the todo list message text.
/// </summary>
/// <value>
/// When <see langword="null"/> (the default), the provider generates a standard formatted list
/// of todo items. When set, this function receives the current list of todo items and should
/// return a formatted string to inject as a user message.
/// </value>
public Func<IReadOnlyList<TodoItem>, string>? TodoListMessageBuilder { get; set; }
}
@@ -233,7 +233,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent))
{
string key = kvMatch.Groups[1].Value;
string value = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value;
string value = kvMatch.Groups[2].Success
? kvMatch.Groups[2].Value
: ParseYamlScalarValue(yamlContent, kvMatch);
if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase))
{
@@ -540,6 +542,66 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
return false;
}
private static string ParseYamlScalarValue(string yamlContent, Match kvMatch)
{
string value = kvMatch.Groups[3].Value;
if (value.Length == 0 || value[0] is not ('|' or '>'))
{
return value;
}
char scalarStyle = value[0];
bool keepTrailingNewline = value.Length > 1 && value[1] == '+';
int nextLineStart = yamlContent.IndexOf('\n', kvMatch.Index + kvMatch.Length);
if (nextLineStart < 0)
{
return value;
}
nextLineStart++;
var blockLines = new List<string>();
using var reader = new StringReader(yamlContent.Substring(nextLineStart));
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (string.IsNullOrWhiteSpace(line))
{
blockLines.Add(string.Empty);
continue;
}
if (line[0] != ' ' && line[0] != '\t')
{
break;
}
blockLines.Add(line);
}
if (blockLines.Count == 0)
{
return string.Empty;
}
int commonIndent = blockLines
.Where(line => line.Length > 0)
.Min(line => line.TakeWhile(ch => ch == ' ' || ch == '\t').Count());
string[] normalizedLines = blockLines
.Select(line => line.Length == 0 ? string.Empty : line.Substring(Math.Min(commonIndent, line.Length)))
.ToArray();
string parsedValue = scalarStyle == '|'
? string.Join("\n", normalizedLines)
: string.Join(" ", normalizedLines.Where(line => line.Length > 0));
return keepTrailingNewline ? parsedValue + "\n" : parsedValue;
}
/// <summary>
/// Normalizes a relative path or directory name by stripping a leading "./"/".\",
/// trimming trailing separators, and replacing backslashes with forward
@@ -21,6 +21,7 @@ internal static class DiagnosticIds
internal const string AIResponseContinuations = MEAIExperiments;
internal const string AIMcpServers = MEAIExperiments;
internal const string AIFunctionApprovals = MEAIExperiments;
internal const string AIOpenAIRequestPolicies = MEAIExperiments;
// These diagnostic IDs are defined by the OpenAI package for its experimental APIs.
// We use the same IDs so consumers do not need to suppress additional diagnostics
@@ -21,6 +21,9 @@ internal static class TestSettings
public const string AzureAIModelDeploymentName = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT";
// Foundry Hosted Agents (Foundry.Hosting integration tests)
public const string FoundryHostingItImage = "IT_HOSTED_AGENT_IMAGE";
// Copilot Studio
public const string CopilotStudioAgentAppId = "COPILOTSTUDIO_AGENT_APP_ID";
public const string CopilotStudioDirectConnectUrl = "COPILOTSTUDIO_DIRECT_CONNECT_URL";
@@ -0,0 +1,8 @@
**/bin/
**/obj/
.git/
.gitignore
.dockerignore
README.md
*.user
*.suo
@@ -0,0 +1,6 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "foundry-hosting-it-test-container.dll"]
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Foundry.Hosting.IntegrationTests.TestContainer</RootNamespace>
<AssemblyName>foundry-hosting-it-test-container</AssemblyName>
<IsPackable>false</IsPackable>
<IsTestProject>false</IsTestProject>
<UseMicrosoftTestingPlatformRunner>false</UseMicrosoftTestingPlatformRunner>
<TestingPlatformDotnetTestSupport>false</TestingPlatformDotnetTestSupport>
<NoWarn>$(NoWarn);NU1605;NU1903;AAIP001;OPENAI001</NoWarn>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageReference Remove="xunit.v3.mtp-v2" />
<PackageReference Remove="xunit.runner.visualstudio" />
<PackageReference Remove="Moq" />
<PackageReference Remove="xRetry.v3" />
<PackageReference Remove="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Remove="Microsoft.NET.Test.Sdk" />
<Using Remove="Xunit" />
<Using Remove="xRetry.v3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
</Project>
@@ -0,0 +1,122 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
// Foundry hosted agent test container for Foundry.Hosting.IntegrationTests.
//
// One image, many scenarios. The IT_SCENARIO environment variable selects which agent
// behavior is wired up at startup. Each scenario corresponds to one test fixture and
// one set of tests in the IT project.
//
// The platform injects FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_AGENT_NAME, FOUNDRY_AGENT_VERSION,
// PORT, and APPLICATIONINSIGHTS_CONNECTION_STRING. We never set FOUNDRY_* or AGENT_* names
// from the test side because they are reserved by the platform.
var scenario = Environment.GetEnvironmentVariable("IT_SCENARIO") ?? "happy-path";
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
var projectClient = new AIProjectClient(projectEndpoint, new DefaultAzureCredential());
AIAgent agent = scenario switch
{
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
"toolbox" => CreateToolboxAgent(projectClient, deployment),
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
};
var builder = WebApplication.CreateBuilder(args);
var port = Environment.GetEnvironmentVariable("PORT");
if (!string.IsNullOrEmpty(port))
{
builder.WebHost.UseUrls($"http://+:{port}");
}
builder.Services.AddFoundryResponses(agent);
var app = builder.Build();
app.MapFoundryResponses();
app.MapGet("/readiness", () => Results.Ok());
app.Run();
static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful AI assistant. Always reply with exactly the single word ECHO unless the user explicitly asks a question that requires a different answer.",
name: "happy-path-agent",
description: "Round trip and conversation test agent.");
static AIAgent CreateToolCallingAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant. Use the GetUtcNow and Multiply tools when appropriate.",
name: "tool-calling-agent",
description: "Server side tool calling test agent.",
tools: [
AIFunctionFactory.Create(GetUtcNow),
AIFunctionFactory.Create(Multiply)
]);
static AIAgent CreateToolCallingApprovalAgent(AIProjectClient client, string deployment) =>
// TODO: wire approval required AIFunction once the public surface is finalized.
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant. Use the SendEmail tool when asked to send a message; it requires user approval before running.",
name: "tool-calling-approval-agent",
description: "Approval flow test agent (placeholder).",
tools: [
AIFunctionFactory.Create(SendEmail)
]);
static AIAgent CreateToolboxAgent(AIProjectClient client, string deployment) =>
// TODO: wire Foundry toolbox host once API surface is finalized for hosted agents.
client.AsAIAgent(
model: deployment,
instructions: "You are a toolbox enabled assistant. Use GetEnvironmentName when asked.",
name: "toolbox-agent",
description: "Toolbox test agent (placeholder).",
tools: [
AIFunctionFactory.Create(GetEnvironmentName)
]);
static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) =>
// TODO: wire MCP toolbox client to https://learn.microsoft.com/api/mcp.
client.AsAIAgent(
model: deployment,
instructions: "You are an assistant with access to Microsoft Learn documentation via MCP.",
name: "mcp-toolbox-agent",
description: "MCP toolbox test agent (placeholder).");
static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deployment) =>
// TODO: substitute custom IResponsesStorageProvider in DI.
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant.",
name: "custom-storage-agent",
description: "Custom storage test agent (placeholder).");
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
[Description("Multiplies two integers and returns the product.")]
static int Multiply([Description("First operand")] int a, [Description("Second operand")] int b) => a * b;
[Description("Sends an email. Requires user approval.")]
static string SendEmail(
[Description("Recipient address")] string to,
[Description("Email subject")] string subject) =>
$"Email sent to {to} with subject '{subject}'.";
[Description("Returns the deployment environment name.")]
static string GetEnvironmentName() => "integration-test";
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests for a hosted agent whose container wires an in memory custom storage provider
/// in place of the platform default. Verifies the model still works and that multi turn
/// behavior reads from the custom store.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class CustomStorageHostedAgentTests(CustomStorageHostedAgentFixture fixture)
: IClassFixture<CustomStorageHostedAgentFixture>
{
private readonly CustomStorageHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task RoundTrip_WorksWithCustomStorageAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Reply with the word 'stored'.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task MultiTurn_PreviousResponseId_ReadsFromCustomStoreAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act
var first = await agent.RunAsync("My favorite city is Lisbon. Acknowledge briefly.", session);
Assert.False(string.IsNullOrWhiteSpace(first.Text));
var second = await agent.RunAsync("What city did I just tell you?", session);
// Assert
Assert.Contains("Lisbon", second.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=custom-storage</c> mode.
/// The container substitutes the default Responses storage provider with a custom in memory
/// implementation so tests can verify that conversation history is read from and written to
/// the custom store rather than the platform default.
/// </summary>
public sealed class CustomStorageHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "custom-storage";
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=happy-path</c> mode.
/// Used by tests that exercise the basic Responses protocol round trip, multi turn behavior
/// (via <c>previous_response_id</c> and <c>conversation_id</c>), and the <c>stored=false</c> flag.
/// </summary>
public sealed class HappyPathHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "happy-path";
}
@@ -0,0 +1,275 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Base fixture for Foundry Hosted Agent integration tests.
///
/// Each derived fixture represents one scenario (happy path, tool calling, toolbox, etc.) and
/// targets a stable, scenario-keyed agent name (e.g. <c>it-happy-path</c>). The fixture creates
/// a new <see cref="ProjectsAgentVersion"/> on each <see cref="InitializeAsync"/>, polls until
/// active, patches the agent's endpoint to route 100% of traffic to that new version, then
/// exposes the wrapped <see cref="AIAgent"/> for tests via <see cref="Agent"/>.
///
/// On <see cref="DisposeAsync"/> only the version created by this fixture is removed; the agent
/// itself (and therefore its managed identity) is left in place. This is critical because the
/// agent's managed identity must hold <c>Azure AI User</c> on the project scope to serve
/// inbound inference traffic, and that role assignment is lost when the agent itself is deleted.
///
/// Prerequisite: each scenario agent (and its managed identity) must exist and have
/// <c>Azure AI User</c> pre-granted on the project scope before the tests run. See
/// <c>scripts/it-bootstrap-agents.ps1</c>.
///
/// The container image is the same for every scenario; the scenario itself is selected by
/// the <c>IT_SCENARIO</c> environment variable in <see cref="HostedAgentDefinition.EnvironmentVariables"/>,
/// configured by each derived fixture via <see cref="ScenarioName"/>.
/// </summary>
public abstract class HostedAgentFixture : IAsyncLifetime
{
private const string ScenarioEnvironmentVariable = "IT_SCENARIO";
private const string RunIdEnvironmentVariable = "IT_RUN_ID";
private const string FoundryFeaturesHeader = "Foundry-Features";
private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview";
private const string EnableVnextExperienceMetadataKey = "enableVnextExperience";
private AgentAdministrationClient _adminClient = null!;
/// <summary>
/// Scenario keyword passed to the container as <c>IT_SCENARIO</c>. Derived fixtures override.
/// </summary>
protected abstract string ScenarioName { get; }
/// <summary>
/// CPU request for the hosted agent container. Override per scenario if needed.
/// </summary>
protected virtual string Cpu => "0.25";
/// <summary>
/// Memory request for the hosted agent container. Override per scenario if needed.
/// </summary>
protected virtual string Memory => "0.5Gi";
/// <summary>
/// Maximum time to wait for <see cref="AgentVersionStatus.Active"/> after creation.
/// </summary>
protected virtual TimeSpan ProvisioningTimeout => TimeSpan.FromMinutes(5);
/// <summary>
/// The wrapped agent. Available after <see cref="InitializeAsync"/>.
/// </summary>
public AIAgent Agent { get; private set; } = null!;
/// <summary>
/// The stable, scenario keyed agent name registered in Foundry (e.g. <c>it-happy-path</c>).
/// The agent itself is provisioned out of band (see <c>scripts/it-bootstrap-agents.ps1</c>);
/// each test run only adds and removes a version under it.
/// </summary>
public string AgentName { get; private set; } = null!;
/// <summary>
/// The agent version assigned by Foundry on creation.
/// </summary>
public string AgentVersion { get; private set; } = null!;
/// <summary>
/// The underlying <see cref="AIProjectClient"/>, useful for tests that need to talk
/// to the conversations or responses APIs directly (e.g. to assert chain visibility).
/// </summary>
public AIProjectClient ProjectClient { get; private set; } = null!;
/// <summary>
/// Creates a server side conversation that tests can pass via <c>ChatOptions.ConversationId</c>
/// to exercise multi turn flows backed by the Foundry conversations service.
/// </summary>
public async Task<string> CreateConversationAsync()
{
var response = await this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().CreateProjectConversationAsync().ConfigureAwait(false);
return response.Value.Id;
}
/// <summary>
/// Deletes a previously created conversation. Used by tests in their cleanup blocks.
/// </summary>
public async Task DeleteConversationAsync(string conversationId)
{
try
{
await this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(conversationId).ConfigureAwait(false);
}
catch
{
// Best effort cleanup mirroring DisposeAsync.
}
}
/// <summary>
/// Counts items currently stored in a conversation. Used by tests verifying that a
/// <c>stored=false</c> request did not append to the conversation.
/// </summary>
public async Task<int> CountConversationItemsAsync(string conversationId)
{
var count = 0;
await foreach (var _ in this.ProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc").ConfigureAwait(false))
{
count++;
}
return count;
}
public async ValueTask InitializeAsync()
{
var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
var image = TestConfiguration.GetRequiredValue(TestSettings.FoundryHostingItImage);
var credential = TestAzureCliCredentials.CreateAzureCliCredential();
var adminOptions = new AgentAdministrationClientOptions();
adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
this._adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions);
this.ProjectClient = new AIProjectClient(endpoint, credential);
this.AgentName = $"it-{this.ScenarioName}";
var definition = new HostedAgentDefinition(cpu: this.Cpu, memory: this.Memory)
{
Image = image,
};
definition.Versions.Add(new ProtocolVersionRecord(ProjectsAgentProtocol.Responses, "1.0.0"));
definition.EnvironmentVariables[ScenarioEnvironmentVariable] = this.ScenarioName;
// Foundry deduplicates versions by content hash, so a fixture re-using the same
// definition would just receive the bootstrap version and then delete it on dispose.
// Adding a per-run env var forces a brand new version that the dispose can safely remove
// without touching the bootstrap version (which keeps the agent alive across runs).
definition.EnvironmentVariables[RunIdEnvironmentVariable] = Guid.NewGuid().ToString("N");
// Allow derived fixtures to layer additional environment variables before submission.
this.ConfigureEnvironment(definition.EnvironmentVariables);
var creationOptions = new ProjectsAgentVersionCreationOptions(definition);
creationOptions.Metadata[EnableVnextExperienceMetadataKey] = "true";
// Adds a new version under the (stable) agent name. Auto-creates the agent on first run.
// The agent is intentionally never deleted because its managed identity must hold the
// pre-granted role assignment for inbound inference to succeed (see class docs).
var version = await this._adminClient.CreateAgentVersionAsync(this.AgentName, creationOptions).ConfigureAwait(false);
var activeVersion = await WaitForActiveAsync(this._adminClient, version.Value, this.ProvisioningTimeout).ConfigureAwait(false);
this.AgentVersion = activeVersion.Version;
// The agent endpoint must already be configured to route via @latest. The bootstrap
// script (scripts/it-bootstrap-agents.ps1) does that one-time per agent. Each new
// version we create automatically becomes the served one because @latest resolves
// to the highest version number.
//
// Build a per-agent ProjectOpenAIClient (the cached projectClient.ProjectOpenAIClient is bound
// to the project-level URL and cannot serve a hosted agent). AgentName on the options selects
// the per-agent URL suffix `/agents/{name}/endpoint/protocols/openai`. The Foundry-Features
// header is also required on the invocation pipeline (not just the admin one) for hosted agents.
var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this.AgentName };
openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions);
var responsesClient = openAIClient.GetProjectResponsesClient();
this.Agent = responsesClient.AsIChatClient().AsAIAgent(name: this.AgentName);
}
public async ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
if (this._adminClient is null || this.AgentName is null || this.AgentVersion is null)
{
return;
}
try
{
// Delete only the version we created. The agent itself MUST stay so that its
// managed identity (and the pre-granted Azure AI User role on it) survive across
// test runs. If we delete the agent, Foundry mints a new MI on the next create
// and inference fails with PermissionDenied until the role is regranted.
await this._adminClient.DeleteAgentVersionAsync(this.AgentName, this.AgentVersion).ConfigureAwait(false);
}
catch
{
// Best effort cleanup. Never throw from DisposeAsync because that would mask
// the real test failure. Orphan versions accumulate harmlessly; a maintenance
// script can prune them when needed.
}
}
/// <summary>
/// Hook for derived fixtures to add scenario specific environment variables.
/// Reserved names (anything matching <c>FOUNDRY_*</c> or <c>AGENT_*</c>) are forbidden by the platform.
/// </summary>
protected virtual void ConfigureEnvironment(IDictionary<string, string> environment)
{
}
private static async Task<ProjectsAgentVersion> WaitForActiveAsync(
AgentAdministrationClient adminClient,
ProjectsAgentVersion version,
TimeSpan timeout)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while (version.Status != AgentVersionStatus.Active && version.Status != AgentVersionStatus.Failed)
{
if (DateTimeOffset.UtcNow > deadline)
{
throw new TimeoutException(
$"Hosted agent '{version.Name}' version '{version.Version}' did not become Active within {timeout.TotalSeconds:F0}s. Last status: {version.Status}.");
}
await Task.Delay(TimeSpan.FromMilliseconds(500), CancellationToken.None).ConfigureAwait(false);
version = (await adminClient.GetAgentVersionAsync(version.Name, version.Version).ConfigureAwait(false)).Value;
}
if (version.Status != AgentVersionStatus.Active)
{
throw new InvalidOperationException(
$"Hosted agent '{version.Name}' version '{version.Version}' failed to deploy. Status: {version.Status}.");
}
return version;
}
/// <summary>
/// Pipeline policy that adds the Foundry feature header on every request.
/// Required for hosted agent operations until the V1 preview flag is removed.
/// </summary>
private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
this.SetHeader(message);
ProcessNext(message, pipeline, currentIndex);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
this.SetHeader(message);
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
private void SetHeader(PipelineMessage message)
{
// Set rather than Add to avoid duplicate headers if the pipeline reprocesses
// the request (retries) or if multiple policies attempt to set the same key.
message.Request.Headers.Remove(FoundryFeaturesHeader);
message.Request.Headers.Add(FoundryFeaturesHeader, features);
}
}
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=mcp-toolbox</c> mode.
/// The container connects to a public MCP server (the Microsoft Learn MCP endpoint) so tests
/// can verify MCP tool discovery and invocation flowing through the Foundry hosted agent.
/// </summary>
public sealed class McpToolboxHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "mcp-toolbox";
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=tool-calling-approval</c> mode.
/// The container declares an AIFunction tagged <c>RequiresApproval=true</c> so tests can exercise
/// the human in the loop approval flow (request, grant, deny).
/// </summary>
public sealed class ToolCallingApprovalHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "tool-calling-approval";
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=tool-calling</c> mode.
/// The container declares one or more deterministic AIFunctions on the server side
/// (e.g. <c>GetUtcNow</c>, <c>Multiply(int,int)</c>) so tests can verify tool invocation behavior
/// without requiring approvals.
/// </summary>
public sealed class ToolCallingHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "tool-calling";
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=toolbox</c> mode.
/// The container hosts a Foundry toolbox with at least one server registered tool. Tests verify
/// that the model can invoke those tools and that client side toolbox additions surface alongside
/// server side registrations when listed.
/// </summary>
public sealed class ToolboxHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "toolbox";
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!--
Constrained to net10.0: Microsoft.Agents.AI.Foundry.Hosting targets net8/9/10 only
(no net472 — depends on ASP.NET Core), while AgentConformance.IntegrationTests
inherits the default tests TFM list (net10.0;net472). The intersection is net10.0.
-->
<TargetFrameworks>net10.0</TargetFrameworks>
<NoWarn>$(NoWarn);CS8793;NU1605;NU1903;AAIP001</NoWarn>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
</Project>
@@ -0,0 +1,210 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Round trip and conversation oriented integration tests against a hosted Responses agent.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class HappyPathHostedAgentTests(HappyPathHostedAgentFixture fixture) : IClassFixture<HappyPathHostedAgentFixture>
{
private readonly HappyPathHostedAgentFixture _fixture = fixture;
[Fact]
public async Task RunAsync_ReturnsNonEmptyTextAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Reply with a short greeting.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
[Fact]
public async Task RunStreamingAsync_YieldsAtLeastOneUpdateAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var collected = new System.Collections.Generic.List<string>();
await foreach (var update in agent.RunStreamingAsync("Reply with a short greeting."))
{
if (!string.IsNullOrEmpty(update.Text))
{
collected.Add(update.Text);
}
}
// Assert
Assert.NotEmpty(collected);
Assert.False(string.IsNullOrWhiteSpace(string.Concat(collected)));
}
[Fact]
public async Task MultiTurn_WithPreviousResponseId_PreservesContextAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act
var first = await agent.RunAsync("My favorite number is 42. Acknowledge briefly.", session);
Assert.False(string.IsNullOrWhiteSpace(first.Text));
var second = await agent.RunAsync("What number did I just tell you?", session);
// Assert
Assert.Contains("42", second.Text);
}
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
public async Task MultiTurn_WithConversationId_PreservesContextAsync()
{
// Arrange
var agent = this._fixture.Agent;
var conversationId = await this._fixture.CreateConversationAsync();
try
{
var options = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
// Act
var first = await agent.RunAsync("My favorite color is teal. Acknowledge briefly.", options: options);
Assert.False(string.IsNullOrWhiteSpace(first.Text));
var second = await agent.RunAsync("What color did I just tell you?", options: options);
// Assert
Assert.Contains("teal", second.Text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await this._fixture.DeleteConversationAsync(conversationId);
}
}
[Fact]
public async Task StoredFalse_Baseline_DoesNotPersistResponseAsync()
{
// Arrange
var agent = this._fixture.Agent;
var options = new ChatClientAgentRunOptions(new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }
});
// Act
var response = await agent.RunAsync("Reply with the word 'pong'.", options: options);
// Assert: response returned but the response id is not retrievable from the chain.
Assert.False(string.IsNullOrWhiteSpace(response.Text));
var responseId = response.ResponseId;
Assert.False(string.IsNullOrWhiteSpace(responseId));
// Attempting to fetch the response should fail because nothing was stored.
var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient();
await Assert.ThrowsAnyAsync<Exception>(() => responsesClient.GetResponseAsync(responseId));
}
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
public async Task StoredFalse_WithPreviousResponseId_ReadsHistoryButDoesNotAppendAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Turn 1 is stored so the chain head exists.
var first = await agent.RunAsync("Remember the number 73. Acknowledge briefly.", session);
// Turn 2 is stored=false but reads from turn 1 via the same session.
var optionsNoStore = new ChatClientAgentRunOptions(new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }
});
// Act
var second = await agent.RunAsync("What number did I just tell you?", session, optionsNoStore);
// Assert: model received history (knows the number) but the new response is not persisted.
Assert.Contains("73", second.Text);
var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient();
await Assert.ThrowsAnyAsync<Exception>(() => responsesClient.GetResponseAsync(second.ResponseId!));
}
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
public async Task StoredFalse_WithConversationId_ReadsHistoryButDoesNotAppendAsync()
{
// Arrange
var agent = this._fixture.Agent;
var conversationId = await this._fixture.CreateConversationAsync();
try
{
var stored = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
var notStored = new ChatClientAgentRunOptions(new ChatOptions
{
ConversationId = conversationId,
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }
});
// Turn 1 stored, populates the conversation.
await agent.RunAsync("Remember the number 99. Acknowledge briefly.", options: stored);
var beforeCount = await this._fixture.CountConversationItemsAsync(conversationId);
// Act: turn 2 reads from conversation but is not appended.
var second = await agent.RunAsync("What number did I just tell you?", options: notStored);
// Assert
Assert.Contains("99", second.Text);
var afterCount = await this._fixture.CountConversationItemsAsync(conversationId);
Assert.Equal(beforeCount, afterCount);
}
finally
{
await this._fixture.DeleteConversationAsync(conversationId);
}
}
[Fact(Skip = "Test container does not yet emit usable response_id / conversation_id chains; see Foundry.Hosting.IntegrationTests.TestContainer/Program.cs.")]
public async Task StoredTrue_Default_PersistsResponseInChainAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Reply with the word 'ack'.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
var responsesClient = this._fixture.ProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient();
var fetched = await responsesClient.GetResponseAsync(response.ResponseId!);
Assert.NotNull(fetched.Value);
}
[Fact]
public async Task Instructions_FromContainerDefinition_AreObeyedAsync()
{
// Arrange: the container side instructions for happy-path enforce a single word reply
// (e.g. "Always reply with exactly the single word ECHO."). See TestContainer/Program.cs.
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Say something useful.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("ECHO", response.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Extensions.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests for an MCP backed toolbox: the hosted container connects to a public MCP server
/// (the Microsoft Learn MCP endpoint) at startup and exposes its tools to the model.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class McpToolboxHostedAgentTests(McpToolboxHostedAgentFixture fixture)
: IClassFixture<McpToolboxHostedAgentFixture>
{
private readonly McpToolboxHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task McpTool_IsInvokedSuccessfullyAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Use the Microsoft Learn MCP tool to look up 'Azure AI Foundry'. Reply with one short paragraph.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.True(response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any()),
"Expected at least one MCP tool invocation in the response messages.");
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task McpTool_WithStructuredArguments_ReturnsValidResultAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Use the MCP search tool with the query 'agent framework hosted agents'. Reply with at least one fact.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task McpTool_ProducesUsableResponseAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Tell me one thing about Microsoft Foundry that would only be in MS Learn docs.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
}
@@ -0,0 +1,144 @@
# Foundry.Hosting.IntegrationTests
Integration tests for `Microsoft.Agents.AI.Foundry.Hosting` against real Foundry hosted agents.
## How it works
Each test class is bound to a scenario fixture (e.g. `HappyPathHostedAgentFixture`,
`ToolCallingHostedAgentFixture`). On `InitializeAsync` the fixture:
1. Reads `AZURE_AI_PROJECT_ENDPOINT` and `IT_HOSTED_AGENT_IMAGE` from the environment.
2. Targets a stable, scenario keyed agent name (e.g. `it-happy-path`). The agent is
provisioned out of band by `scripts/it-bootstrap-agents.ps1`; tests only manage versions.
3. Calls `AgentAdministrationClient.CreateAgentVersionAsync` with a `HostedAgentDefinition`
that points at the image, sets `IT_SCENARIO=<scenario>` in the container env vars, and
adds a per-run `IT_RUN_ID` so each run gets a fresh content-addressed version (Foundry
deduplicates versions by definition hash).
4. Polls until the agent reports `AgentVersionStatus.Active` (timeout: 5 minutes).
5. Patches the agent endpoint with `AgentEndpointConfig` (Responses protocol, version
selector pointing 100% at the new version).
6. Builds a per-agent `ProjectOpenAIClient` with `AgentName` set on the options (this
selects the `/agents/{name}/endpoint/protocols/openai` URL suffix; the cached
`projectClient.ProjectOpenAIClient` cannot serve a hosted agent), wraps the
`ProjectResponsesClient` as an `AIAgent`, and exposes it via `Agent`.
On `DisposeAsync` only the version created by this fixture is deleted. The agent itself
is intentionally never deleted, because its managed identity must hold the pre-granted
`Azure AI User` role on the project scope for inbound inference to succeed.
The container image is **the same for every scenario**. The `IT_SCENARIO` env var, set on
the agent definition by each fixture, drives a `switch` in the test container's
`Program.cs` to wire up the scenario specific behavior (tools, toolbox, custom storage,
etc.).
## Required environment variables
| Variable | Source | Purpose |
| --- | --- | --- |
| `AZURE_AI_PROJECT_ENDPOINT` | Foundry project | Where to provision the agent. Must be in a region that has the Hosted Agents preview enabled (e.g. East US 2). |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Foundry project | Model the agent uses. Defaults to `gpt-4o` inside the container. |
| `IT_HOSTED_AGENT_IMAGE` | `scripts/it-build-image.ps1` | ACR image reference the agent points at. |
## One-time bootstrap (per Foundry project)
Hosted agent invocation requires the agent's own managed identity to hold the
`Azure AI User` role on the project scope. Because each agent's MI is created when the
agent is first provisioned (and recycled on agent delete), the bootstrap creates the
six stable scenario agents once and grants the role to each MI. The fixture then only
manages versions under those existing agents, so the role grants survive across runs.
```powershell
./scripts/it-bootstrap-agents.ps1 `
-ProjectEndpoint "https://<account>.services.ai.azure.com/api/projects/<project>" `
-Image "<acr>.azurecr.io/foundry-hosting-it:<tag>"
```
The script is idempotent. It requires Owner or User Access Administrator on the project
scope (RBAC writes). Wait ~3 minutes after first-time grants for AAD propagation before
running the tests.
## Building and pushing the test container image
The test container source lives at `dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer`.
Build and push it with:
```powershell
$env:IT_REGISTRY = "<your-acr>.azurecr.io"
$env:IT_HOSTED_AGENT_IMAGE = (./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Select-String IT_HOSTED_AGENT_IMAGE).Line.Split('=', 2)[1]
```
The script tags the image by content hash of the test container source. If you didn't
change anything since the last build, the push is a no op.
The Foundry project's account MI and project MI both need `AcrPull` on the registry.
## Running the tests locally
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-4o"
# IT_HOSTED_AGENT_IMAGE was set above.
dotnet test dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj
```
> **Note:** all tests are currently tagged `[Fact(Skip = ...)]` until end to end smoke
> verification has run against a live Foundry deployment. Once a scenario has been
> exercised and the assertions stabilized, remove the Skip annotation on its tests.
All test classes carry `[Trait("Category", "FoundryHostedAgents")]` so the CI workflow can
route them to a separate Foundry project than the rest of the integration tests (see
`.github/workflows/dotnet-build-and-test.yml`).
## CI wiring
The main "Run Integration Tests" step excludes this category. Two extra steps run only on
`ubuntu-latest` for this category, gated on `paths-filter.outputs.foundryHostingChanges`
so they execute only when the project under test, its dependency chain, the test
container, the test fixture, or their tooling changed:
1. **Build and push Foundry Hosted Agents test container** invokes
`scripts/it-build-image.ps1` against `vars.IT_HOSTED_AGENT_REGISTRY`. The image is
rebuilt every IT run; its tag is content-hashed across the test container source AND
its referenced framework projects (`Microsoft.Agents.AI.Foundry.Hosting`,
`Microsoft.Agents.AI.Foundry`, `Microsoft.Agents.AI`, `Microsoft.Agents.AI.Abstractions`),
so unchanged content is a `docker push` no-op while any framework code change forces
a fresh image. The script pipes its `IT_HOSTED_AGENT_IMAGE=<tag>` line into
`$GITHUB_ENV` for the next step.
2. **Run Foundry Hosted Agents Integration Tests** executes only `--filter-trait
"Category=FoundryHostedAgents"` with the env vars below mapped onto the names the
fixture reads. `IT_HOSTED_AGENT_IMAGE` is the value just exported by step 1.
| GitHub env var | Mapped to |
| --- | --- |
| `IT_HOSTED_AGENT_PROJECT_ENDPOINT` | `AZURE_AI_PROJECT_ENDPOINT` |
| `IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME` | `AZURE_AI_MODEL_DEPLOYMENT_NAME` |
| `IT_HOSTED_AGENT_REGISTRY` | (consumed by `it-build-image.ps1`; not passed to tests) |
Like all integration tests in this workflow, the steps run only on `push` and merge-queue
events, never on plain `pull_request`. The path-filter list lives in the `paths-filter`
job in `.github/workflows/dotnet-build-and-test.yml` under `filters.foundryHosting` and
must stay in sync with `$hashedDirs` in `scripts/it-build-image.ps1`.
The CI service principal that backs `secrets.AZURE_CLIENT_ID` needs:
- `Azure AI User` on the hosted-agents Foundry project (to add/delete agent versions).
- `AcrPush` on the registry referenced by `IT_HOSTED_AGENT_REGISTRY` (to push the image).
The bootstrap script (and one-time `AcrPull` grants for the Foundry project's MIs) is a
human-only operation; CI only adds and deletes versions under existing agents.
## Scenarios
| Fixture | `IT_SCENARIO` | Agent name | What it tests |
| --- | --- | --- | --- |
| `HappyPathHostedAgentFixture` | `happy-path` | `it-happy-path` | Round trip, streaming, multi turn (`previous_response_id` and `conversation_id`), `stored=false` flag in three combinations, instructions obeyed. |
| `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. |
| `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. |
| `ToolboxHostedAgentFixture` | `toolbox` | `it-toolbox` | Server registered toolbox tool callable; client side additions visible (placeholder). |
| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). |
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
The placeholder scenarios will be wired up in the test container `Program.cs` once the
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
@@ -0,0 +1,86 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Extensions.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests for the human in the loop tool approval flow: the container declares an AIFunction
/// flagged as requiring approval, and the model raises a <see cref="ToolApprovalRequestContent"/>
/// before the tool executes.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class ToolCallingApprovalHostedAgentTests(ToolCallingApprovalHostedAgentFixture fixture)
: IClassFixture<ToolCallingApprovalHostedAgentFixture>
{
private readonly ToolCallingApprovalHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ApprovalRequiredTool_RaisesApprovalRequestAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Run the SendEmail tool with subject='hi' to test@example.com.");
// Assert
var approvalRequest = response.Messages
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
.FirstOrDefault();
Assert.NotNull(approvalRequest);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ApprovalGranted_ToolRunsAndResponseReflectsResultAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
var first = await agent.RunAsync("Run the SendEmail tool with subject='ok' to test@example.com.", session);
var approvalRequest = first.Messages
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
.First();
var approvalResponse = approvalRequest.CreateResponse(approved: true);
var followUp = new ChatMessage(ChatRole.User, [approvalResponse]);
// Act
var second = await agent.RunAsync([followUp], session);
// Assert: model received the tool result and produced a final response.
Assert.False(string.IsNullOrWhiteSpace(second.Text));
var hasFurtherApprovalRequest = second.Messages
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
.Any();
Assert.False(hasFurtherApprovalRequest, "Did not expect another approval request after granting.");
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ApprovalDenied_ToolDoesNotRunAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
var first = await agent.RunAsync("Run the SendEmail tool with subject='no' to test@example.com.", session);
var approvalRequest = first.Messages
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
.First();
var approvalResponse = approvalRequest.CreateResponse(approved: false);
var followUp = new ChatMessage(ChatRole.User, [approvalResponse]);
// Act
var second = await agent.RunAsync([followUp], session);
// Assert: no FunctionResultContent for SendEmail in the response.
Assert.False(string.IsNullOrWhiteSpace(second.Text));
var sendEmailResults = second.Messages
.SelectMany(m => m.Contents.OfType<FunctionResultContent>())
.Where(r => r.CallId == approvalRequest.ToolCall?.CallId);
Assert.Empty(sendEmailResults);
}
}
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Extensions.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests that exercise server side tool invocation by a hosted agent. The container
/// declares deterministic AIFunctions (e.g. <c>GetUtcNow</c>, <c>Multiply</c>) and the
/// model decides whether to call them based on the prompt.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class ToolCallingHostedAgentTests(ToolCallingHostedAgentFixture fixture) : IClassFixture<ToolCallingHostedAgentFixture>
{
private readonly ToolCallingHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ServerSideTool_IsInvokedWhenPromptedAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("What is the current UTC date and time? Use the GetUtcNow tool.");
// Assert: response references a timestamp (very loose check; deterministic-ish).
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.True(response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any()),
"Expected at least one FunctionCallContent in the response messages.");
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ServerSideTool_NotInvokedWhenNotNeededAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Say hello in one word.");
// Assert: no tool call expected for a simple greeting.
Assert.False(string.IsNullOrWhiteSpace(response.Text));
var toolCallCount = response.Messages.SelectMany(m => m.Contents.OfType<FunctionCallContent>()).Count();
Assert.Equal(0, toolCallCount);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ServerSideTool_MultiTurn_RemembersPriorToolResultAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act
var first = await agent.RunAsync("Multiply 6 by 7 using the Multiply tool. Reply with the result.", session);
Assert.Contains("42", first.Text);
var second = await agent.RunAsync("What was the result of the last multiplication?", session);
// Assert
Assert.Contains("42", second.Text);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ServerSideTool_WithArguments_ReturnsExpectedResultAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Use the Multiply tool with a=12 and b=11. Reply with just the numeric result.");
// Assert
Assert.Contains("132", response.Text);
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests for the Foundry toolbox: the hosted container registers tools via the toolbox API
/// (server side), and tests can also add tools client side. The model should be able to
/// invoke tools from both sources.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class ToolboxHostedAgentTests(ToolboxHostedAgentFixture fixture) : IClassFixture<ToolboxHostedAgentFixture>
{
private readonly ToolboxHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ServerRegisteredToolboxTool_IsCallableAsync()
{
// Arrange: the container side toolbox registers GetEnvironmentName which returns a constant.
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Call GetEnvironmentName via the toolbox and reply with just the value.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("integration-test", response.Text, System.StringComparison.OrdinalIgnoreCase);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ClientSideAddedToolboxTool_IsListedAndCallableAsync()
{
// TODO: requires AgentToolboxes API surface. Placeholder asserting the test runs.
var agent = this._fixture.Agent;
var response = await agent.RunAsync("List all tools you have access to.");
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ListingTools_ReturnsBothServerAndClientSideEntriesAsync()
{
// TODO: requires AgentAdministrationClient toolbox listing. Placeholder.
var agent = this._fixture.Agent;
var response = await agent.RunAsync("Briefly describe what tools are available.");
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
}
@@ -0,0 +1,162 @@
#requires -Version 7.0
<#
.SYNOPSIS
One-time bootstrap of stable hosted agents for the Foundry.Hosting.IntegrationTests suite.
.DESCRIPTION
The IT fixture targets stable, scenario-keyed agent names (e.g. it-happy-path) and only
manages versions on each test run. The agent itself must already exist AND its managed
identity must hold the Azure AI User role on the project scope, otherwise inbound
inference calls fail with HTTP 500 PermissionDenied.
This script idempotently creates each scenario agent (with a placeholder version) and
grants Azure AI User on the project to its managed identity. Re-run it safely; existing
agents and role assignments are left in place.
.PARAMETER ProjectEndpoint
Foundry project endpoint, e.g. https://<account>.services.ai.azure.com/api/projects/<project>
.PARAMETER Image
Container image reference for the placeholder version (e.g. <acr>.azurecr.io/foundry-hosting-it:<tag>).
Use the value emitted by scripts/it-build-image.ps1.
.EXAMPLE
./it-bootstrap-agents.ps1 `
-ProjectEndpoint "https://my-acct.services.ai.azure.com/api/projects/my-proj" `
-Image "myacr.azurecr.io/foundry-hosting-it:abc123"
#>
param(
[Parameter(Mandatory)] [string] $ProjectEndpoint,
[Parameter(Mandatory)] [string] $Image
)
$ErrorActionPreference = 'Stop'
$Scenarios = @(
'happy-path',
'tool-calling',
'tool-calling-approval',
'toolbox',
'mcp-toolbox',
'custom-storage'
)
# Resolve project ARM scope from the endpoint.
$endpointUri = [Uri]$ProjectEndpoint
$accountName = $endpointUri.Host.Split('.')[0]
$projectName = ($endpointUri.AbsolutePath.TrimEnd('/') -split '/')[-1]
$accountInfo = az cognitiveservices account list --query "[?name=='$accountName'].{name:name, rg:resourceGroup, sub:id}" | ConvertFrom-Json
if (-not $accountInfo) { throw "Could not find Cognitive Services account '$accountName'." }
$rg = $accountInfo[0].rg
$sub = ($accountInfo[0].sub -split '/')[2]
$projectScope = "/subscriptions/$sub/resourceGroups/$rg/providers/Microsoft.CognitiveServices/accounts/$accountName/projects/$projectName"
Write-Host "Project scope: $projectScope"
$tok = az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv
$headers = @{
Authorization = "Bearer $tok"
'Foundry-Features' = 'HostedAgents=V1Preview'
'Content-Type' = 'application/json'
}
foreach ($scenario in $Scenarios) {
$agentName = "it-$scenario"
Write-Host ""
Write-Host "=== $agentName ==="
# 1. Ensure the agent exists. Create a placeholder version if it doesn't.
$agent = $null
try {
$agent = Invoke-RestMethod -Method GET -Headers $headers `
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1"
Write-Host " agent exists"
} catch {
if ($_.Exception.Response.StatusCode -ne 404) { throw }
}
if (-not $agent) {
Write-Host " creating placeholder version..."
$body = @{
definition = @{
kind = 'hosted'
container_protocol_versions = @(@{ protocol = 'responses'; version = '1.0.0' })
cpu = '0.25'
memory = '0.5Gi'
environment_variables = @{ IT_SCENARIO = $scenario }
image = $Image
}
metadata = @{ enableVnextExperience = 'true' }
} | ConvertTo-Json -Depth 10
Invoke-RestMethod -Method POST -Headers $headers `
-Uri "$ProjectEndpoint/agents/$agentName/versions`?api-version=v1" `
-Body $body | Out-Null
Start-Sleep 5
$agent = Invoke-RestMethod -Method GET -Headers $headers `
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1"
}
$principalId = $agent.versions.latest.instance_identity.principal_id
Write-Host " agent MI: $principalId"
# 2. PATCH the agent endpoint to route via @latest if not already configured.
# Using @latest means each new version added by the IT fixture automatically becomes the
# served version, no per-run PATCH needed (which is good because the strongly-typed
# PATCH wrapper is alpha-only on Azure.AI.Projects right now).
$hasLatestSelector = $agent.agent_endpoint -and `
($agent.agent_endpoint.version_selector.version_selection_rules | Where-Object { $_.agent_version -eq '@latest' })
if ($hasLatestSelector) {
Write-Host " endpoint already routes via @latest"
} else {
Write-Host " patching endpoint to route via @latest..."
$patchBody = @{
agent_endpoint = @{
version_selector = @{
version_selection_rules = @(@{
type = 'FixedRatio'
agent_version = '@latest'
traffic_percentage = 100
})
}
protocols = @('responses')
}
} | ConvertTo-Json -Depth 10
Invoke-RestMethod -Method PATCH -Headers $headers `
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1" `
-Body $patchBody | Out-Null
}
# 3. Grant Azure AI User on the project scope to the agent MI (idempotent).
$existing = az role assignment list --assignee $principalId --scope $projectScope `
--query "[?roleDefinitionName=='Azure AI User']" 2>$null | ConvertFrom-Json
if ($existing) {
Write-Host " role already assigned"
} else {
Write-Host " granting Azure AI User..."
$maxAttempts = 12
$granted = $false
for ($i = 1; $i -le $maxAttempts; $i++) {
$output = az role assignment create `
--assignee-object-id $principalId `
--assignee-principal-type ServicePrincipal `
--role 'Azure AI User' `
--scope $projectScope 2>&1
if ($LASTEXITCODE -eq 0) {
$granted = $true
break
}
if ($output -match 'Cannot find user or service principal in graph') {
Write-Host " attempt $i/$maxAttempts : MI not yet in AAD graph, retrying in 15s..."
Start-Sleep 15
continue
}
throw "az role assignment failed: $output"
}
if (-not $granted) {
throw "MI '$principalId' did not appear in AAD graph after $maxAttempts attempts."
}
Write-Host " granted (RBAC propagation may take 1-3 minutes)"
}
}
Write-Host ""
Write-Host "Done. Wait ~3 minutes after first-time grants before running the tests."
@@ -0,0 +1,131 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Builds and pushes the Foundry.Hosting.IntegrationTests.TestContainer image to a container registry.
.DESCRIPTION
The integration tests in dotnet/tests/Foundry.Hosting.IntegrationTests provision real
Foundry hosted agents that point at a container image. This script builds and pushes that
image, then emits the IT_HOSTED_AGENT_IMAGE=... line that the tests read from the
environment.
.PARAMETER Registry
The container registry login server, e.g. mycompany.azurecr.io. Required. There is no
default because every team and every dev may use a different registry.
.PARAMETER Repository
Image repository name within the registry. Defaults to foundry-hosting-it.
.PARAMETER TestContainerProject
Path to the test container csproj. Defaults to the in repo location.
.EXAMPLE
PS> ./scripts/it-build-image.ps1 -Registry mycompany.azurecr.io
IT_HOSTED_AGENT_IMAGE=mycompany.azurecr.io/foundry-hosting-it:abc123def456
.EXAMPLE
Local dev, set the env var directly:
PS> $env:IT_REGISTRY = "mycompany.azurecr.io"
PS> $env:IT_HOSTED_AGENT_IMAGE = (./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Select-String IT_HOSTED_AGENT_IMAGE).Line.Split('=', 2)[1]
.EXAMPLE
CI workflow, assumes IT_REGISTRY is set in the environment:
- name: Build IT image
run: pwsh ./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Tee-Object -FilePath $env:GITHUB_ENV
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Registry,
[string] $Repository = "foundry-hosting-it",
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer"
)
$ErrorActionPreference = "Stop"
# Resolve to the repo root regardless of the caller's PWD so all relative paths used below
# (TestContainerProject, the framework src dirs hashed for the image tag) resolve correctly.
# This script lives at <repoRoot>/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/.
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "../../../..")).Path
Push-Location $RepoRoot
try {
if (-not (Test-Path $TestContainerProject)) {
throw "Test container project not found at '$TestContainerProject' (repo root '$RepoRoot')."
}
# Strip any scheme/trailing slash from the registry, then derive the ACR short name.
$Registry = $Registry -replace '^https?://', '' -replace '/+$', ''
$registryHost = $Registry.Split('.')[0]
if ([string]::IsNullOrWhiteSpace($registryHost)) {
throw "Could not derive ACR short name from -Registry '$Registry'."
}
# Hash the test container source content AND the source of all referenced framework projects
# so any edit (in TestContainer OR in dotnet/src/Microsoft.Agents.AI.Foundry*/) produces a new
# tag. The TestContainer image embeds compiled output of those projects, so a framework code
# change must invalidate the tag for `docker push` to publish a new layer; a TestContainer-only
# hash silently reused stale images on framework edits.
#
# Keep this list in sync with the `foundryHosting` paths-filter in
# .github/workflows/dotnet-build-and-test.yml so CI gating and image tagging cover the same set.
$hashedDirs = @(
$TestContainerProject,
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting",
"dotnet/src/Microsoft.Agents.AI.Foundry",
"dotnet/src/Microsoft.Agents.AI",
"dotnet/src/Microsoft.Agents.AI.Abstractions",
"dotnet/src/Microsoft.Agents.AI.Workflows"
)
$sourceFiles = @()
foreach ($dir in $hashedDirs) {
if (Test-Path $dir) {
$sourceFiles += @(git -c core.quotepath=false ls-files -- $dir)
}
}
if ($sourceFiles.Count -eq 0) {
throw "No tracked files found under any of: $($hashedDirs -join ', ')"
}
$fileHashes = git hash-object -- $sourceFiles
$shaInput = ($fileHashes -join "`n" | git hash-object --stdin).Trim()
$tag = $shaInput.Substring(0, 12)
$image = "$Registry/$Repository`:$tag"
Write-Host "Publishing $TestContainerProject ..." -ForegroundColor Cyan
$out = Join-Path $TestContainerProject "out"
if (Test-Path $out) {
Remove-Item -Recurse -Force $out
}
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out --tl:off | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
Write-Host "Building $image ..." -ForegroundColor Cyan
docker build -t $image -f (Join-Path $TestContainerProject "Dockerfile") $TestContainerProject | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "docker build failed with exit code $LASTEXITCODE."
}
Write-Host "Pushing $image ..." -ForegroundColor Cyan
az acr login -n $registryHost | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "az acr login failed with exit code $LASTEXITCODE."
}
docker push $image | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "docker push failed with exit code $LASTEXITCODE."
}
# Emit the env var line for shells / CI consumption.
"IT_HOSTED_AGENT_IMAGE=$image"
}
finally {
Pop-Location
}
@@ -102,18 +102,20 @@ public sealed class AGUIChatMessageExtensionsTests
new AGUISystemMessage { Id = "msg1", Content = "System message" },
new AGUIUserMessage { Id = "msg2", Content = "User message" },
new AGUIAssistantMessage { Id = "msg3", Content = "Assistant message" },
new AGUIDeveloperMessage { Id = "msg4", Content = "Developer message" }
new AGUIDeveloperMessage { Id = "msg4", Content = "Developer message" },
new AGUIReasoningMessage { Id = "msg5", Content = "Reasoning message" }
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(4, chatMessages.Count);
Assert.Equal(5, chatMessages.Count);
Assert.Equal(ChatRole.System, chatMessages[0].Role);
Assert.Equal(ChatRole.User, chatMessages[1].Role);
Assert.Equal(ChatRole.Assistant, chatMessages[2].Role);
Assert.Equal("developer", chatMessages[3].Role.Value);
Assert.Equal(ChatRole.Assistant, chatMessages[4].Role);
}
[Fact]
@@ -367,6 +369,277 @@ public sealed class AGUIChatMessageExtensionsTests
Assert.Equal(ChatRole.Tool, role);
}
[Fact]
public void AsChatMessages_WithReasoningMessage_ConvertsToTextReasoningContent()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIReasoningMessage
{
Id = "reason1",
Content = "I need to consider the user's request.",
EncryptedValue = "ErgDCkgIDB..."
}
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
ChatMessage message = Assert.Single(chatMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("reason1", message.MessageId);
var reasoningContent = Assert.IsType<TextReasoningContent>(message.Contents[0]);
Assert.Equal("I need to consider the user's request.", reasoningContent.Text);
Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData);
}
[Fact]
public void AsChatMessages_WithReasoningMessageWithoutEncryptedValue_ConvertsToTextReasoningContent()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIReasoningMessage
{
Id = "reason1",
Content = "Thinking about this problem."
}
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
ChatMessage message = Assert.Single(chatMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
var reasoningContent = Assert.IsType<TextReasoningContent>(message.Contents[0]);
Assert.Equal("Thinking about this problem.", reasoningContent.Text);
Assert.Null(reasoningContent.ProtectedData);
}
[Fact]
public void AsChatMessages_WithReasoningMessageWithOnlyEncryptedValue_ConvertsToTextReasoningContent()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIReasoningMessage
{
Id = "reason1",
Content = string.Empty,
EncryptedValue = "ErgDCkgIDB..."
}
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
ChatMessage message = Assert.Single(chatMessages);
var reasoningContent = Assert.IsType<TextReasoningContent>(message.Contents[0]);
Assert.Equal("", reasoningContent.Text);
Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData);
}
[Fact]
public void AsChatMessages_WithEmptyReasoningMessage_ProducesEmptyContents()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIReasoningMessage
{
Id = "reason1",
Content = string.Empty
}
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
ChatMessage message = Assert.Single(chatMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Empty(message.Contents);
}
[Fact]
public void MapChatRole_WithReasoningRole_ReturnsAssistantChatRole()
{
// Arrange & Act
ChatRole role = AGUIChatMessageExtensions.MapChatRole(AGUIRoles.Reasoning);
// Assert
Assert.Equal(ChatRole.Assistant, role);
}
[Fact]
public void AsChatMessages_WithMixedMessagesIncludingReasoning_PreservesOrder()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIUserMessage { Id = "msg1", Content = "What is 2+2?" },
new AGUIReasoningMessage { Id = "msg2", Content = "I need to add 2 and 2.", EncryptedValue = "tok-123" },
new AGUIAssistantMessage { Id = "msg3", Content = "The answer is 4." }
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(3, chatMessages.Count);
Assert.Equal(ChatRole.User, chatMessages[0].Role);
Assert.Equal(ChatRole.Assistant, chatMessages[1].Role);
Assert.IsType<TextReasoningContent>(chatMessages[1].Contents[0]);
Assert.Equal(ChatRole.Assistant, chatMessages[2].Role);
Assert.Equal("The answer is 4.", chatMessages[2].Text);
}
[Fact]
public void AsAGUIMessages_WithReasoningContent_ProducesReasoningMessage()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("I need to think about this.") { ProtectedData = "encrypted-tok-1" }
]) { MessageId = "reason-1" }
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
AGUIMessage message = Assert.Single(aguiMessages);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
Assert.Equal("reason-1", reasoningMessage.Id);
Assert.Equal(AGUIRoles.Reasoning, reasoningMessage.Role);
Assert.Equal("I need to think about this.", reasoningMessage.Content);
Assert.Equal("encrypted-tok-1", reasoningMessage.EncryptedValue);
}
[Fact]
public void AsAGUIMessages_WithReasoningContentWithoutProtectedData_ProducesReasoningMessage()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("Just thinking.")
]) { MessageId = "reason-2" }
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
AGUIMessage message = Assert.Single(aguiMessages);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
Assert.Equal("Just thinking.", reasoningMessage.Content);
Assert.Null(reasoningMessage.EncryptedValue);
}
[Fact]
public void AsAGUIMessages_WithMultipleReasoningChunksInOneMessage_ConcatenatesText()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("First part. "),
new TextReasoningContent("Second part.") { ProtectedData = "final-token" }
]) { MessageId = "reason-3" }
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
AGUIMessage message = Assert.Single(aguiMessages);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
Assert.Equal("First part. Second part.", reasoningMessage.Content);
Assert.Equal("final-token", reasoningMessage.EncryptedValue);
}
[Fact]
public void AsAGUIMessages_WithMixedReasoningAndTextContent_EmitsBothMessages()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("Thinking about the answer.") { ProtectedData = "enc-tok" },
new TextContent("The answer is 42.")
]) { MessageId = "msg-mixed" }
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(2, aguiMessages.Count);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(aguiMessages[0]);
Assert.Equal("msg-mixed", reasoningMessage.Id);
Assert.Equal("Thinking about the answer.", reasoningMessage.Content);
Assert.Equal("enc-tok", reasoningMessage.EncryptedValue);
var assistantMessage = Assert.IsType<AGUIAssistantMessage>(aguiMessages[1]);
Assert.Equal("msg-mixed", assistantMessage.Id);
Assert.Equal("The answer is 42.", assistantMessage.Content);
}
[Fact]
public void AsAGUIMessages_WithReasoningAndToolCallInSameMessage_EmitsBothMessages()
{
// Arrange
var arguments = new Dictionary<string, object?> { ["location"] = "Seattle" };
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("I should look up the weather."),
new FunctionCallContent("call-1", "GetWeather", arguments)
]) { MessageId = "msg-toolcall" }
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(2, aguiMessages.Count);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(aguiMessages[0]);
Assert.Equal("I should look up the weather.", reasoningMessage.Content);
var assistantMessage = Assert.IsType<AGUIAssistantMessage>(aguiMessages[1]);
Assert.NotNull(assistantMessage.ToolCalls);
var toolCall = Assert.Single(assistantMessage.ToolCalls);
Assert.Equal("call-1", toolCall.Id);
Assert.Equal("GetWeather", toolCall.Function.Name);
}
[Fact]
public void RoundTrip_ReasoningMessage_PreservesData()
{
// Arrange
List<ChatMessage> originalMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("Thinking about the problem.") { ProtectedData = "ErgDCkgIDB..." }
]) { MessageId = "reason-rt" }
];
// Act - Convert to AGUI and back
AGUIMessage aguiMessage = originalMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).Single();
List<AGUIMessage> aguiList = [aguiMessage];
ChatMessage reconstructed = aguiList.AsChatMessages(AGUIJsonSerializerContext.Default.Options).Single();
// Assert
Assert.Equal(ChatRole.Assistant, reconstructed.Role);
var reasoningContent = Assert.IsType<TextReasoningContent>(reconstructed.Contents[0]);
Assert.Equal("Thinking about the problem.", reasoningContent.Text);
Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData);
}
#region Custom Type Serialization Tests
[Fact]
@@ -64,6 +64,49 @@ public sealed class AGUIJsonSerializerContextTests
Assert.Single(input.Messages);
}
[Fact]
public void RunAgentInput_Deserializes_FromJsonWithReasoningMessages()
{
// Arrange
const string Json = """
{
"threadId": "thread1",
"runId": "run1",
"messages": [
{
"id": "m1",
"role": "user",
"content": "Hello"
},
{
"id": "m2",
"role": "reasoning",
"content": "I need to consider this.",
"encryptedValue": "ErgDCkgIDB..."
},
{
"id": "m3",
"role": "assistant",
"content": "Here is my answer."
}
]
}
""";
// Act
RunAgentInput? input = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.RunAgentInput);
// Assert
Assert.NotNull(input);
var messages = input.Messages.ToList();
Assert.Equal(3, messages.Count);
Assert.IsType<AGUIUserMessage>(messages[0]);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(messages[1]);
Assert.Equal("I need to consider this.", reasoningMessage.Content);
Assert.Equal("ErgDCkgIDB...", reasoningMessage.EncryptedValue);
Assert.IsType<AGUIAssistantMessage>(messages[2]);
}
[Fact]
public void RunAgentInput_HandlesOptionalFields_StateContextAndForwardedProperties()
{
@@ -963,7 +1006,76 @@ public sealed class AGUIJsonSerializerContextTests
}
[Fact]
public void AllFiveMessageTypes_SerializeAsPolymorphicArray_Correctly()
public void AGUIReasoningMessage_SerializesAndDeserializes_Correctly()
{
// Arrange
var originalMessage = new AGUIReasoningMessage
{
Id = "reason1",
Content = "I need to consider the user's request carefully.",
EncryptedValue = "ErgDCkgIDB..."
};
// Act
string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("reason1", deserialized.Id);
Assert.Equal("I need to consider the user's request carefully.", deserialized.Content);
Assert.Equal("ErgDCkgIDB...", deserialized.EncryptedValue);
Assert.Equal(AGUIRoles.Reasoning, deserialized.Role);
}
[Fact]
public void AGUIReasoningMessage_WithoutEncryptedValue_SerializesAndDeserializes_Correctly()
{
// Arrange
var originalMessage = new AGUIReasoningMessage
{
Id = "reason2",
Content = "Thinking about this problem."
};
// Act
string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("reason2", deserialized.Id);
Assert.Equal("Thinking about this problem.", deserialized.Content);
Assert.Null(deserialized.EncryptedValue);
}
[Fact]
public void AGUIReasoningMessage_DeserializesViaPolymorphicConverter_Correctly()
{
// Arrange
const string Json = """
{
"id": "reason1",
"role": "reasoning",
"content": "Let me think about this.",
"encryptedValue": "tok-encrypted"
}
""";
// Act
AGUIMessage? message = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.AGUIMessage);
// Assert
Assert.NotNull(message);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
Assert.Equal("reason1", reasoningMessage.Id);
Assert.Equal(AGUIRoles.Reasoning, reasoningMessage.Role);
Assert.Equal("Let me think about this.", reasoningMessage.Content);
Assert.Equal("tok-encrypted", reasoningMessage.EncryptedValue);
}
[Fact]
public void AllSixMessageTypes_SerializeAsPolymorphicArray_Correctly()
{
// Arrange
AGUIMessage[] messages =
@@ -972,7 +1084,8 @@ public sealed class AGUIJsonSerializerContextTests
new AGUIDeveloperMessage { Id = "2", Content = "Developer message" },
new AGUIUserMessage { Id = "3", Content = "User message" },
new AGUIAssistantMessage { Id = "4", Content = "Assistant message" },
new AGUIToolMessage { Id = "5", ToolCallId = "call_1", Content = "{\"result\":\"success\"}" }
new AGUIToolMessage { Id = "5", ToolCallId = "call_1", Content = "{\"result\":\"success\"}" },
new AGUIReasoningMessage { Id = "6", Content = "Reasoning message", EncryptedValue = "tok-123" }
];
// Act
@@ -981,12 +1094,13 @@ public sealed class AGUIJsonSerializerContextTests
// Assert
Assert.NotNull(deserialized);
Assert.Equal(5, deserialized.Length);
Assert.Equal(6, deserialized.Length);
Assert.IsType<AGUISystemMessage>(deserialized[0]);
Assert.IsType<AGUIDeveloperMessage>(deserialized[1]);
Assert.IsType<AGUIUserMessage>(deserialized[2]);
Assert.IsType<AGUIAssistantMessage>(deserialized[3]);
Assert.IsType<AGUIToolMessage>(deserialized[4]);
Assert.IsType<AGUIReasoningMessage>(deserialized[5]);
}
#endregion
@@ -1111,4 +1225,149 @@ public sealed class AGUIJsonSerializerContextTests
}
#endregion
#region Reasoning Event Serialization Tests
[Fact]
public void ReasoningStartEvent_Serializes_WithCorrectTypeDiscriminator()
{
// Arrange
ReasoningStartEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningStartEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningStart, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
}
[Fact]
public void ReasoningMessageStartEvent_Serializes_WithRoleReasoningAndMessageId()
{
// Arrange
ReasoningMessageStartEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageStartEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageStart, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
Assert.Equal("reasoning", jsonElement.GetProperty("role").GetString());
}
[Fact]
public void ReasoningMessageContentEvent_Serializes_WithDeltaAndMessageId()
{
// Arrange
ReasoningMessageContentEvent evt = new() { MessageId = "reason1", Delta = "I am thinking" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageContentEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageContent, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
Assert.Equal("I am thinking", jsonElement.GetProperty("delta").GetString());
}
[Fact]
public void ReasoningMessageEndEvent_Serializes_WithMessageId()
{
// Arrange
ReasoningMessageEndEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageEndEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageEnd, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
}
[Fact]
public void ReasoningEndEvent_Serializes_WithMessageId()
{
// Arrange
ReasoningEndEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningEndEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningEnd, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
}
[Fact]
public void ReasoningMessageChunkEvent_Serializes_WithDeltaAndMessageId()
{
// Arrange
ReasoningMessageChunkEvent evt = new() { MessageId = "reason1", Delta = "chunk" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageChunkEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageChunk, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
Assert.Equal("chunk", jsonElement.GetProperty("delta").GetString());
}
[Fact]
public void ReasoningEncryptedValueEvent_Serializes_WithAllFields()
{
// Arrange
ReasoningEncryptedValueEvent evt = new() { EntityId = "reason1", EncryptedValue = "tok-abc123" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningEncryptedValueEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningEncryptedValue, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("entityId").GetString());
Assert.Equal("tok-abc123", jsonElement.GetProperty("encryptedValue").GetString());
Assert.Equal("message", jsonElement.GetProperty("subtype").GetString());
}
[Fact]
public void AllReasoningEventTypes_DeserializeViaBaseEventConverter_ToCorrectTypes()
{
// Arrange
BaseEvent[] events =
[
new ReasoningStartEvent { MessageId = "r1" },
new ReasoningMessageStartEvent { MessageId = "r1" },
new ReasoningMessageContentEvent { MessageId = "r1", Delta = "thinking" },
new ReasoningMessageEndEvent { MessageId = "r1" },
new ReasoningEndEvent { MessageId = "r1" },
new ReasoningMessageChunkEvent { MessageId = "r1", Delta = "chunk" },
new ReasoningEncryptedValueEvent { EntityId = "r1", EncryptedValue = "tok" }
];
// Act
string json = JsonSerializer.Serialize(events, AGUIJsonSerializerContext.Default.Options);
var deserialized = JsonSerializer.Deserialize<BaseEvent[]>(json, AGUIJsonSerializerContext.Default.Options);
// Assert
Assert.NotNull(deserialized);
Assert.Equal(7, deserialized.Length);
Assert.IsType<ReasoningStartEvent>(deserialized[0]);
Assert.IsType<ReasoningMessageStartEvent>(deserialized[1]);
Assert.IsType<ReasoningMessageContentEvent>(deserialized[2]);
Assert.IsType<ReasoningMessageEndEvent>(deserialized[3]);
Assert.IsType<ReasoningEndEvent>(deserialized[4]);
Assert.IsType<ReasoningMessageChunkEvent>(deserialized[5]);
Assert.IsType<ReasoningEncryptedValueEvent>(deserialized[6]);
}
#endregion Reasoning Event Serialization Tests
}
@@ -777,4 +777,469 @@ public sealed class ChatResponseUpdateAGUIExtensionsTests
}
#endregion State Delta Tests
#region Reasoning Tests
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageEndForWrongMessageId_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageStartEvent { MessageId = "reason1" },
new ReasoningMessageContentEvent { MessageId = "reason1", Delta = "thinking..." },
new ReasoningMessageEndEvent { MessageId = "reason2" } // Wrong message ID
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Consume stream to trigger exception
}
});
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningContent_EmitsCorrectReasoningEventSequenceAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("I need to think about this")]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.IsType<RunStartedEvent>(outputEvents[0]);
var reasoningStart = Assert.IsType<ReasoningStartEvent>(outputEvents[1]);
var reasoningId = reasoningStart.MessageId;
Assert.NotEqual("reason1", reasoningId);
var reasoningMessageStart = Assert.IsType<ReasoningMessageStartEvent>(outputEvents[2]);
var reasoningMessageId = reasoningMessageStart.MessageId;
Assert.NotEqual(reasoningId, reasoningMessageId);
var reasoningContent = Assert.IsType<ReasoningMessageContentEvent>(outputEvents[3]);
Assert.Equal(reasoningMessageId, reasoningContent.MessageId);
Assert.Equal("I need to think about this", reasoningContent.Delta);
var reasoningMessageEnd = Assert.IsType<ReasoningMessageEndEvent>(outputEvents[4]);
Assert.Equal(reasoningMessageId, reasoningMessageEnd.MessageId);
var reasoningEnd = Assert.IsType<ReasoningEndEvent>(outputEvents[5]);
Assert.Equal(reasoningId, reasoningEnd.MessageId);
Assert.IsType<RunFinishedEvent>(outputEvents[6]);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithMultipleReasoningDeltas_EmitsContentEventPerDeltaAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("First")]) { MessageId = "reason1" },
new(ChatRole.Assistant, [new TextReasoningContent(" step")]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
var contentEvents = outputEvents.OfType<ReasoningMessageContentEvent>().ToList();
Assert.Equal(2, contentEvents.Count);
Assert.Equal("First", contentEvents[0].Delta);
Assert.Equal(" step", contentEvents[1].Delta);
// Only one START/END pair
Assert.Single(outputEvents.OfType<ReasoningStartEvent>());
Assert.Single(outputEvents.OfType<ReasoningMessageStartEvent>());
Assert.Single(outputEvents.OfType<ReasoningMessageEndEvent>());
Assert.Single(outputEvents.OfType<ReasoningEndEvent>());
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningAndProtectedData_EmitsEncryptedValueEventAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking") { ProtectedData = "encrypted-abc" }]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
var reasoningMessageId = outputEvents.OfType<ReasoningMessageStartEvent>().Single().MessageId;
Assert.NotEqual("reason1", reasoningMessageId);
var encryptedEvent = outputEvents.OfType<ReasoningEncryptedValueEvent>().Single();
Assert.Equal(reasoningMessageId, encryptedEvent.EntityId);
Assert.Equal("encrypted-abc", encryptedEvent.EncryptedValue);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningFollowedByText_EmitsBothEventSequencesAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "reason1" },
new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "msg1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.Contains(outputEvents, e => e is ReasoningStartEvent);
Assert.Contains(outputEvents, e => e is ReasoningMessageContentEvent);
Assert.Contains(outputEvents, e => e is ReasoningEndEvent);
Assert.Contains(outputEvents, e => e is TextMessageStartEvent);
Assert.Contains(outputEvents, e => e is TextMessageContentEvent);
Assert.Contains(outputEvents, e => e is TextMessageEndEvent);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningAndTextSharingSameMessageId_EmitsDistinctEventIdsAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "shared1" },
new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
var reasoningId = outputEvents.OfType<ReasoningStartEvent>().Single().MessageId;
var reasoningMessageId = outputEvents.OfType<ReasoningMessageStartEvent>().Single().MessageId;
var textMessageId = outputEvents.OfType<TextMessageStartEvent>().Single().MessageId;
Assert.NotEqual(reasoningId, reasoningMessageId);
Assert.NotEqual(reasoningId, textMessageId);
Assert.NotEqual(reasoningMessageId, textMessageId);
Assert.Equal("shared1", textMessageId);
Assert.All(outputEvents.OfType<ReasoningMessageContentEvent>(), e => Assert.Equal(reasoningMessageId, e.MessageId));
Assert.Equal(reasoningMessageId, outputEvents.OfType<ReasoningMessageEndEvent>().Single().MessageId);
Assert.Equal(reasoningId, outputEvents.OfType<ReasoningEndEvent>().Single().MessageId);
Assert.All(outputEvents.OfType<TextMessageContentEvent>(), e => Assert.Equal("shared1", e.MessageId));
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningThenTextSharingSameMessageId_ClosesReasoningBlockBeforeTextStartAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "shared1" },
new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
int reasoningMessageEndIndex = outputEvents.FindIndex(e => e is ReasoningMessageEndEvent);
int reasoningEndIndex = outputEvents.FindIndex(e => e is ReasoningEndEvent);
int textMessageStartIndex = outputEvents.FindIndex(e => e is TextMessageStartEvent);
Assert.True(reasoningMessageEndIndex < textMessageStartIndex);
Assert.True(reasoningEndIndex < textMessageStartIndex);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningThenToolCallSharingSameMessageId_ClosesReasoningBlockBeforeToolCallStartAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking about which tool to use")]) { MessageId = "shared1" },
new(ChatRole.Assistant, [new FunctionCallContent("call-1", "GetWeather", new Dictionary<string, object?> { ["location"] = "Seattle" })]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
int reasoningEndIndex = outputEvents.FindIndex(e => e is ReasoningEndEvent);
int toolCallStartIndex = outputEvents.FindIndex(e => e is ToolCallStartEvent);
Assert.True(reasoningEndIndex < toolCallStartIndex);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningThenToolResultSharingSameMessageId_ClosesReasoningBlockBeforeToolResultAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("reflecting on result")]) { MessageId = "shared1" },
new(ChatRole.Tool, [new FunctionResultContent("call-1", "72F and sunny")]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
int reasoningEndIndex = outputEvents.FindIndex(e => e is ReasoningEndEvent);
int toolCallResultIndex = outputEvents.FindIndex(e => e is ToolCallResultEvent);
Assert.True(reasoningEndIndex < toolCallResultIndex);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageSequence_ProducesTextReasoningContentPerDeltaAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningStartEvent { MessageId = "reason1" },
new ReasoningMessageStartEvent { MessageId = "reason1" },
new ReasoningMessageContentEvent { MessageId = "reason1", Delta = "First thought" },
new ReasoningMessageContentEvent { MessageId = "reason1", Delta = " and more" },
new ReasoningMessageEndEvent { MessageId = "reason1" },
new ReasoningEndEvent { MessageId = "reason1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Equal(2, updates.Count);
Assert.All(updates, u => Assert.Equal(ChatRole.Assistant, u.Role));
Assert.All(updates, u => Assert.Equal("reason1", u.MessageId));
var firstContent = Assert.IsType<TextReasoningContent>(updates[0].Contents[0]);
Assert.Equal("First thought", firstContent.Text);
var secondContent = Assert.IsType<TextReasoningContent>(updates[1].Contents[0]);
Assert.Equal(" and more", secondContent.Text);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningStartAndEndEvents_DoNotProduceUpdatesAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningStartEvent { MessageId = "reason1" },
new ReasoningEndEvent { MessageId = "reason1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Empty(updates);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningEncryptedValueEvent_ProducesTextReasoningContentWithProtectedDataAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningEncryptedValueEvent { EntityId = "reason1", EncryptedValue = "secret-token" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
Assert.Equal(ChatRole.Assistant, updates[0].Role);
Assert.Equal("reason1", updates[0].MessageId);
var content = Assert.IsType<TextReasoningContent>(updates[0].Contents[0]);
Assert.Equal("secret-token", content.ProtectedData);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageChunks_ProducesTextReasoningContentPerChunkAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "chunk one" },
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = " chunk two" },
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Equal(2, updates.Count);
Assert.All(updates, u => Assert.Equal(ChatRole.Assistant, u.Role));
var firstContent = Assert.IsType<TextReasoningContent>(updates[0].Contents[0]);
Assert.Equal("chunk one", firstContent.Text);
var secondContent = Assert.IsType<TextReasoningContent>(updates[1].Contents[0]);
Assert.Equal(" chunk two", secondContent.Text);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageChunkEmptyDelta_ProducesNoUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Empty(updates);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageStartWhileMessageInProgress_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageStartEvent { MessageId = "reason1" },
new ReasoningMessageStartEvent { MessageId = "reason2" } // Overlapping start
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Consume stream to trigger exception
}
});
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageEndWithoutStart_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageEndEvent { MessageId = "reason1" } // End without start
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Consume stream to trigger exception
}
});
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithProtectedDataOnly_EmitsEncryptedValueEventWithoutContentDeltaAsync()
{
// Arrange — TextReasoningContent with empty text but non-empty ProtectedData
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("") { ProtectedData = "encrypted-only" }]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.Contains(outputEvents, e => e is ReasoningStartEvent);
Assert.Contains(outputEvents, e => e is ReasoningMessageStartEvent);
Assert.DoesNotContain(outputEvents, e => e is ReasoningMessageContentEvent);
var reasoningMessageId = outputEvents.OfType<ReasoningMessageStartEvent>().Single().MessageId;
Assert.NotEqual("reason1", reasoningMessageId);
var encryptedEvent = outputEvents.OfType<ReasoningEncryptedValueEvent>().Single();
Assert.Equal(reasoningMessageId, encryptedEvent.EntityId);
Assert.Equal("encrypted-only", encryptedEvent.EncryptedValue);
Assert.Contains(outputEvents, e => e is ReasoningMessageEndEvent);
Assert.Contains(outputEvents, e => e is ReasoningEndEvent);
}
[Fact]
public async Task ReasoningContent_RoundTrip_OutboundThenInbound_PreservesTextAndProtectedDataAsync()
{
// Arrange
List<ChatResponseUpdate> outboundUpdates =
[
new(ChatRole.Assistant, [new TextReasoningContent("I'm thinking") { ProtectedData = "enc-value" }]) { MessageId = "reason1" }
];
// Act - outbound: ChatResponseUpdate → AGUI events
List<BaseEvent> aguilEvents = [];
await foreach (BaseEvent evt in outboundUpdates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
aguilEvents.Add(evt);
}
// Act - inbound: AGUI events → ChatResponseUpdate
List<ChatResponseUpdate> inboundUpdates = [];
await foreach (ChatResponseUpdate update in aguilEvents.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
inboundUpdates.Add(update);
}
// Assert
var reasoningContents = inboundUpdates
.SelectMany(u => u.Contents)
.OfType<TextReasoningContent>()
.ToList();
Assert.Contains(reasoningContents, c => c.Text == "I'm thinking");
Assert.Contains(reasoningContents, c => c.ProtectedData == "enc-value");
}
#endregion Reasoning Tests
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Net;
@@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using OpenAI;
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
@@ -134,6 +136,72 @@ public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
}
""";
[Fact]
public void TryApplyUserAgent_RepeatedCalls_OnSameAgent_RegistersPolicyOnce()
{
// Arrange: hosted resolution calls TryApplyUserAgent on every request. Without per-instance
// dedup, each call would append another policy entry to the shared OpenAIRequestPolicies,
// producing unbounded growth on singleton agents (one chat client reused across requests).
using var http = new HttpClient(new NoopHandler());
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"),
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
AIAgent agent = new ChatClientAgent(chatClient);
// Act
for (int i = 0; i < 50; i++)
{
FoundryHostingExtensions.TryApplyUserAgent(agent);
}
// Assert: exactly one HostedAgentUserAgentPolicy entry on the shared OpenAIRequestPolicies.
var policies = chatClient.GetService<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
}
[Fact]
public void TryApplyUserAgent_AcrossDistinctAgents_RegistersPolicyOncePerChatClient()
{
// Arrange: dedup is per-OpenAIRequestPolicies-instance, not global, so two agents on
// different chat clients each get exactly one registration.
using var http1 = new HttpClient(new NoopHandler());
using var http2 = new HttpClient(new NoopHandler());
var client1 = new OpenAIClient(new ApiKeyCredential("k1"),
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http1) });
var client2 = new OpenAIClient(new ApiKeyCredential("k2"),
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http2) });
IChatClient cc1 = client1.GetResponsesClient().AsIChatClient();
IChatClient cc2 = client2.GetResponsesClient().AsIChatClient();
AIAgent a1 = new ChatClientAgent(cc1);
AIAgent a2 = new ChatClientAgent(cc2);
// Act
for (int i = 0; i < 10; i++)
{
FoundryHostingExtensions.TryApplyUserAgent(a1);
FoundryHostingExtensions.TryApplyUserAgent(a2);
}
// Assert
Assert.Equal(1, EntriesCount(cc1.GetService<OpenAIRequestPolicies>()!));
Assert.Equal(1, EntriesCount(cc2.GetService<OpenAIRequestPolicies>()!));
}
private static int EntriesCount(OpenAIRequestPolicies policies)
{
var field = typeof(OpenAIRequestPolicies).GetField("_entries", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
var array = (Array?)field?.GetValue(policies);
return array?.Length ?? -1;
}
private sealed class NoopHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
private sealed class RecordingHandler : HttpClientHandler
{
private readonly string _body;
@@ -780,25 +780,33 @@ public class InputConverterTests
}
[Fact]
public void ConvertItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse_FallsBackToWireIdWhenNoMapping()
public void ConvertItemsToMessages_McpApprovalResponse_ThrowsWhenNoMapping()
{
// Without a recorded ApprovalEntry the converter cannot reconstruct the original
// function call faithfully — any placeholder it produced would still fail downstream
// (FICC has no tool to invoke; Azure's stored function_call can't pair with the
// synthetic id). Fail fast with a clear error instead of continuing into a confusing
// HTTP 400 deep inside the agent loop.
var wireId = "mcpr_" + new string('a', 50);
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: true);
var messages = InputConverter.ConvertItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(wireId, content.RequestId);
Assert.True(content.Approved);
var ex = Assert.Throws<InvalidOperationException>(() => InputConverter.ConvertItemsToMessages([item]));
Assert.Contains(wireId, ex.Message);
}
[Fact]
public void ConvertItemsToMessages_McpApprovalResponse_ResolvesAfRequestIdFromStateBag()
{
const string AfRequestId = "af_request_xyz";
const string AfRequestId = "ficc_call_xyz";
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
var stateBag = new AgentSessionStateBag();
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
ToolApprovalIdMap.Record(
stateBag,
wireId,
AfRequestId,
"call_xyz",
"issue_refund",
"{\"order_id\":123}");
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: false);
@@ -807,6 +815,17 @@ public class InputConverterTests
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(AfRequestId, content.RequestId);
Assert.False(content.Approved);
// Verify the original FunctionCallContent is reconstructed losslessly:
// - CallId matches the model-issued id (without FICC's "ficc_" prefix), so the
// resulting function_call_output pairs with Azure's stored function_call.
// - Name matches the original tool, so FICC can invoke the right function on resume.
// - Arguments are preserved.
var fcc = Assert.IsType<FunctionCallContent>(content.ToolCall);
Assert.Equal("call_xyz", fcc.CallId);
Assert.Equal("issue_refund", fcc.Name);
Assert.NotNull(fcc.Arguments);
Assert.Equal(123, ((System.Text.Json.JsonElement)fcc.Arguments!["order_id"]!).GetInt32());
}
[Fact]
@@ -828,10 +847,16 @@ public class InputConverterTests
[Fact]
public void ConvertOutputItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse()
{
const string AfRequestId = "af_request_history";
const string AfRequestId = "ficc_call_history";
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
var stateBag = new AgentSessionStateBag();
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
ToolApprovalIdMap.Record(
stateBag,
wireId,
AfRequestId,
"call_history",
"delete_file",
"{\"path\":\"/tmp/x\"}");
var item = new OutputItemMcpApprovalResponseResource(
id: "ar_history_id",
@@ -843,6 +868,10 @@ public class InputConverterTests
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(AfRequestId, content.RequestId);
Assert.True(content.Approved);
var fcc = Assert.IsType<FunctionCallContent>(content.ToolCall);
Assert.Equal("call_history", fcc.CallId);
Assert.Equal("delete_file", fcc.Name);
}
[Fact]
@@ -862,6 +891,28 @@ public class InputConverterTests
Assert.Equal("not valid json", fc.Arguments!["_raw"]?.ToString());
}
[Fact]
public void ToolApprovalIdMap_Record_EmptyCallId_IsNoOp()
{
var stateBag = new AgentSessionStateBag();
var wireId = "mcpr_" + new string('d', 50);
ToolApprovalIdMap.Record(stateBag, wireId, "ficc_x", callId: string.Empty, name: "tool", argumentsJson: "{}");
Assert.Null(ToolApprovalIdMap.ResolveEntry(stateBag, wireId));
}
[Fact]
public void ToolApprovalIdMap_Record_EmptyName_IsNoOp()
{
var stateBag = new AgentSessionStateBag();
var wireId = "mcpr_" + new string('e', 50);
ToolApprovalIdMap.Record(stateBag, wireId, "ficc_x", callId: "call_xyz", name: string.Empty, argumentsJson: "{}");
Assert.Null(ToolApprovalIdMap.ResolveEntry(stateBag, wireId));
}
// ── input_file data-URI decoding (TryDecodeTextDataUri) ──
[Fact]
@@ -84,7 +84,7 @@ public class OutputConverterTests
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionCall_EmitsFunctionCallEventsAsync()
public async Task ConvertUpdatesToEventsAsync_FunctionCallWithoutResult_EmitsFunctionCallWireItemAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
@@ -99,10 +99,12 @@ public class OutputConverterTests
events.Add(evt);
}
// Should have: FuncAdded, ArgsDelta, ArgsDone, FuncDone, Completed
Assert.IsType<ResponseOutputItemAddedEvent>(events[0]);
// A lone FunctionCallContent (no paired FunctionResultContent) is the
// OpenAI Responses encoding of a HITL request: the caller is expected to
// resume with a function_call_output for this call_id.
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
Assert.True(events.Count >= 4, $"Expected at least 4 events for function call, got {events.Count}");
}
[Fact]
@@ -302,6 +304,8 @@ public class OutputConverterTests
events.Add(evt);
}
// FCC closes any in-flight assistant message, then emits its own function_call
// wire item. Result: 2 output items (text message + function_call).
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(2, events.OfType<ResponseOutputItemDoneEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
@@ -328,7 +332,7 @@ public class OutputConverterTests
// G-04
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionCallWithEmptyCallId_GeneratesCallIdAsync()
public async Task ConvertUpdatesToEventsAsync_FunctionCallWithEmptyCallId_DoesNotEmitWireItemAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
@@ -342,12 +346,14 @@ public class OutputConverterTests
events.Add(evt);
}
Assert.Contains(events, e => e is ResponseOutputItemAddedEvent);
// Empty CallId is invalid for the wire format; emission is skipped.
Assert.DoesNotContain(events, e => e is ResponseOutputItemAddedEvent);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// G-05
[Fact]
public async Task ConvertUpdatesToEventsAsync_MultipleFunctionCalls_EmitsSeparateBuildersAsync()
public async Task ConvertUpdatesToEventsAsync_MultipleFunctionCallsWithoutResults_EachEmitsWireItemAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
@@ -362,7 +368,10 @@ public class OutputConverterTests
events.Add(evt);
}
// Each lone FCC surfaces as its own function_call wire item (HITL request shape).
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(2, events.OfType<ResponseFunctionCallArgumentsDoneEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// H-02
@@ -537,7 +546,7 @@ public class OutputConverterTests
// K-03
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultContent_IsSkippedWithNoEventsAsync()
public async Task ConvertUpdatesToEventsAsync_FunctionResultWithoutMatchingCall_EmitsFunctionCallOutputAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "result data")] };
@@ -548,8 +557,82 @@ public class OutputConverterTests
events.Add(evt);
}
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
// A FunctionResultContent always emits a function_call_output wire item; pairing
// with a function_call (if any) is established by call_id at the wire layer.
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
Assert.Single(events.OfType<ResponseOutputItemDoneEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// K-04
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionCallThenResult_EmitsPairedItemsAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "search", new Dictionary<string, object?> { ["q"] = "weather" })] },
new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "sunny")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Issue #5662: function_call and function_call_output must both surface as
// wire items so Azure's stored conversation has a paired call+output and
// resume via previous_response_id works.
Assert.Equal(2, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(2, events.OfType<ResponseOutputItemDoneEvent>().Count());
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// K-05: An FCC with an empty CallId is dropped without disturbing in-flight text.
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionCallEmptyCallIdMidText_PreservesTextBoundaryAsync()
{
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hello, ")] },
new AgentResponseUpdate { Contents = [new FunctionCallContent(string.Empty, "skipped", new Dictionary<string, object?>())] },
new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("world!")] },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// The FCC is skipped (no CallId), and because we now validate CallId before
// closing the in-flight assistant message, both text deltas land in the same
// output item — only one message-added event is emitted.
Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// K-06: FRC string results are emitted as raw text on the wire (not JSON-quoted).
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultStringPayload_EmittedAsRawTextAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "sunny")] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var output = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
// String FRC payloads must not be double-encoded — `sunny`, not `"sunny"`.
Assert.Equal("sunny", output.Output.ToString());
}
// L-01
@@ -666,6 +749,7 @@ public class OutputConverterTests
events.Add(evt);
}
// text(msg_1) → function_call(call_1) → text(msg_2): three output items.
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
}
@@ -729,6 +813,7 @@ public class OutputConverterTests
events.Add(evt);
}
// Three output items: function_call(call_1), text(msg_1), function_call(call_2).
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
}
@@ -821,9 +906,10 @@ public class OutputConverterTests
events.Add(evt);
}
// Should have: 4 workflow actions + 1 function call + 1 text message = 6 output items
// Workflow actions: 4. Lone FCC: 1 (function_call wire item).
// Text message: 1. Total output items: 6.
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
Assert.Contains(events, e => e is ResponseTextDeltaEvent);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
@@ -960,11 +1046,10 @@ public class OutputConverterTests
events.Add(evt);
}
// Workflow actions: invoked triage, completed triage, invoked expert, completed expert = 4
// Content items: 1 function call, 1 text message = 2
// Total output items: 6
// Workflow actions: 4. Lone FCC: 1 (function_call wire item).
// Text message: 1. Total output items: 6.
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
// Two text deltas for the two streaming chunks
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
@@ -185,10 +185,10 @@ public class OutputConverterWorkflowTests
}
// Workflow actions: 4 (2 invoked + 2 completed)
// Content: 1 reasoning + 1 function call + 1 text message = 3
// Content: 1 reasoning + 1 function_call (lone FCC = HITL request) + 1 text = 3
// Total: 7 output items
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
Assert.Single(events.OfType<ResponseFunctionCallArgumentsDoneEvent>());
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
@@ -1,452 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Verifies that <see cref="UserAgentResponsesClient"/> preserves user-supplied client options
/// (Transport, RetryPolicy, UserAgentApplicationId, OrganizationId, ProjectId) and adds the
/// hosted-agent User-Agent supplement on every outgoing request, including streaming.
/// Covers both the Azure-flavored <see cref="ProjectResponsesClient"/> and the native OpenAI
/// <see cref="ResponsesClient"/>.
/// </summary>
public sealed partial class UserAgentResponsesClientTests
{
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
private const string OpenAIEndpoint = "https://fake-openai.example.com/v1";
private const string Deployment = "fake-deployment";
[System.Text.RegularExpressions.GeneratedRegex("foundry-hosting/agent-framework-dotnet")]
private static partial System.Text.RegularExpressions.Regex SupplementRegex();
[Fact]
public async Task Polyfill_NonStreaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var chat = MakeWithDelegating(inner);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
Assert.StartsWith(TestEndpoint, req.Uri);
}
[Fact]
public async Task Polyfill_Streaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalSseResponse());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var chat = MakeWithDelegating(inner);
// Act
await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
{
}
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
Assert.StartsWith(TestEndpoint, req.Uri);
}
[Fact]
public async Task Polyfill_PreservesOrganizationAndProjectHeadersAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient,
userAgentApplicationId: "MY_APP_ID",
organizationId: "org_xyz",
projectId: "proj_abc");
var chat = MakeWithDelegating(inner);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
}
[Fact]
public async Task Polyfill_HonorsUserSuppliedRetryPolicy_ByCountingRetriesAsync()
{
// Arrange
var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
var chat = MakeWithDelegating(inner);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert: retry policy ran (1 + 2 extras = 3 attempts).
Assert.Equal(3, handler.Requests.Count);
Assert.Equal(3, retryPolicy.InvocationCount);
foreach (var req in handler.Requests)
{
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
}
}
[Fact]
public async Task Baseline_NonStreaming_DoesNotInjectSupplementAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var chat = inner.AsIChatClient(Deployment);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", req.UserAgent);
}
[Fact]
public async Task Polyfill_NativeOpenAIResponsesClient_NonStreaming_AddsSupplementAsync()
{
// Arrange: use the NATIVE OpenAI SDK ResponsesClient (no Foundry / Azure project involved).
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var chat = MakeWithDelegating(inner);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
Assert.StartsWith(OpenAIEndpoint, req.Uri);
}
[Fact]
public async Task Polyfill_NativeOpenAIResponsesClient_Streaming_AddsSupplementAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalSseResponse());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var chat = MakeWithDelegating(inner);
// Act
await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
{
}
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
Assert.StartsWith(OpenAIEndpoint, req.Uri);
}
[Theory]
[InlineData("DeleteResponseAsync")]
[InlineData("CancelResponseAsync")]
[InlineData("GetInputTokenCountAsync")]
[InlineData("CompactResponseAsync")]
[InlineData("GetResponseInputItemCollectionPageAsync")]
public async Task Polyfill_AncillaryProtocolMethod_AddsSupplementAsync(string method)
{
// Arrange: hit the wrapper DIRECTLY (no MEAI in the chain) to simulate user code that
// grabs the underlying ResponsesClient via chat.GetService<ResponsesClient>() and invokes
// a non-Create/Get protocol method. This is the regression path: without overriding these,
// the wrapper's dummy throwing pipeline would fire.
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var wrapper = new UserAgentResponsesClient(inner);
// Act
switch (method)
{
case "DeleteResponseAsync":
_ = await wrapper.DeleteResponseAsync("resp_1", options: null!);
break;
case "CancelResponseAsync":
_ = await wrapper.CancelResponseAsync("resp_1", options: null!);
break;
case "GetInputTokenCountAsync":
_ = await wrapper.GetInputTokenCountAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
break;
case "CompactResponseAsync":
_ = await wrapper.CompactResponseAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
break;
case "GetResponseInputItemCollectionPageAsync":
_ = await wrapper.GetResponseInputItemCollectionPageAsync("resp_1", limit: null, order: "asc", after: "a", before: "b", options: null!);
break;
default:
Assert.Fail($"Unhandled method: {method}");
break;
}
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
}
[Fact]
public async Task Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgentAsync()
{
// Arrange: a custom retry policy that re-runs the inner pipeline on the SAME message,
// so the per-call HostedAgentUserAgentPolicy fires multiple times against the same headers.
// The policy's Contains-guard must prevent the supplement from appearing twice.
var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
var chat = MakeWithDelegating(inner);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert: each retry attempt must have exactly ONE foundry-hosting segment, never two.
Assert.Equal(3, handler.Requests.Count);
foreach (var req in handler.Requests)
{
int matches = SupplementRegex().Matches(req.UserAgent).Count;
Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment per retry attempt, got {matches}. UA: {req.UserAgent}");
}
}
[Fact]
public async Task TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrapAsync()
{
// Arrange: build a real ChatClientAgent whose IChatClient resolves to MEAI's
// OpenAIResponsesChatClient → ProjectResponsesClient (with a fake transport).
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
IChatClient chatClient = inner.AsIChatClient(Deployment);
AIAgent agent = new ChatClientAgent(chatClient);
// Act: apply twice.
FoundryHostingExtensions.TryApplyUserAgent(agent);
FoundryHostingExtensions.TryApplyUserAgent(agent);
// Assert: invoking the agent produces exactly ONE outbound request whose UA contains
// the supplement EXACTLY ONCE (would be twice if the wrapper were nested).
_ = await chatClient.GetResponseAsync("hello");
var req = Assert.Single(handler.Requests);
int matches = SupplementRegex().Matches(req.UserAgent).Count;
Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment, got {matches}. UA: {req.UserAgent}");
}
[Fact]
public void OpenAIResponsesChatClient_ResponseClientField_ReflectionGuard()
{
// Guards the polyfill's reflection target. Failure here means MEAI internals
// changed and the polyfill needs updating.
var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
Assert.NotNull(meaiType);
var field = meaiType!.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(field);
Assert.True(typeof(ResponsesClient).IsAssignableFrom(field!.FieldType),
$"Expected _responseClient to be assignable to ResponsesClient but was {field.FieldType}.");
}
[Fact]
public void ResponsesClient_PipelineProperty_ReflectionGuard()
{
// The polyfill design assumes ResponsesClient.Pipeline remains accessible.
var pipelineProp = typeof(ResponsesClient).GetProperty("Pipeline", BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(pipelineProp);
Assert.Equal(typeof(ClientPipeline), pipelineProp!.PropertyType);
}
private static IChatClient MakeWithDelegating(ResponsesClient inner)
{
IChatClient meai = inner.AsIChatClient(Deployment);
var meaiType = meai.GetType();
var field = meaiType.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance)!;
field.SetValue(meai, new UserAgentResponsesClient(inner));
return meai;
}
private static ProjectResponsesClient BuildInner(
HttpClient httpClient,
string? userAgentApplicationId = null,
string? organizationId = null,
string? projectId = null,
PipelinePolicy? retryPolicy = null)
{
var options = new ProjectResponsesClientOptions
{
Transport = new HttpClientPipelineTransport(httpClient),
};
if (userAgentApplicationId is not null)
{
options.UserAgentApplicationId = userAgentApplicationId;
}
if (organizationId is not null)
{
options.OrganizationId = organizationId;
}
if (projectId is not null)
{
options.ProjectId = projectId;
}
if (retryPolicy is not null)
{
options.RetryPolicy = retryPolicy;
}
return new ProjectResponsesClient(new Uri(TestEndpoint), new FakeAuthenticationTokenProvider(), options);
}
private static ResponsesClient BuildOpenAIInner(
HttpClient httpClient,
string? userAgentApplicationId = null)
{
var options = new OpenAIClientOptions
{
Transport = new HttpClientPipelineTransport(httpClient),
Endpoint = new Uri(OpenAIEndpoint),
};
if (userAgentApplicationId is not null)
{
options.UserAgentApplicationId = userAgentApplicationId;
}
return new ResponsesClient(new ApiKeyCredential("test-key"), options);
}
private static string MinimalResponseJson() => """
{
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}
""";
private static string MinimalSseResponse()
{
var sb = new StringBuilder();
sb.Append("event: response.completed\n");
sb.Append("data: ").Append("""{"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1700000000,"status":"completed","model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}""").Append("\n\n");
sb.Append("data: [DONE]\n\n");
return sb.ToString();
}
private sealed class RecordingHandler : HttpClientHandler
{
private readonly string _body;
public List<RecordedRequest> Requests { get; } = [];
public RecordingHandler(string body)
{
this._body = body;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string ua = request.Headers.TryGetValues("User-Agent", out var values)
? string.Join(",", values)
: "(none)";
this.Requests.Add(new RecordedRequest(request.Method.Method, request.RequestUri?.ToString() ?? "?", ua));
var resp = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
RequestMessage = request,
};
return Task.FromResult(resp);
}
}
private readonly record struct RecordedRequest(string Method, string Uri, string UserAgent);
private sealed class CountingRetryPolicy : PipelinePolicy
{
private readonly int _extraAttempts;
public int InvocationCount { get; private set; }
public CountingRetryPolicy(int extraAttempts)
{
this._extraAttempts = extraAttempts;
}
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
for (int i = 0; i <= this._extraAttempts; i++)
{
this.InvocationCount++;
ProcessNext(message, pipeline, currentIndex);
}
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
for (int i = 0; i <= this._extraAttempts; i++)
{
this.InvocationCount++;
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
}
}
}
@@ -0,0 +1,735 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OpenAI;
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Tests for the per-call <c>x-client-*</c> header pipeline:
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/>,
/// <see cref="ClientHeadersExtensions.UseClientHeaders(AIAgentBuilder)"/>,
/// the <c>ClientHeadersAgent</c> decorator, the <c>ClientHeadersScope</c> AsyncLocal,
/// and the <c>ClientHeadersPolicy</c> stamping policy.
/// </summary>
public sealed class ClientHeadersExtensionsTests
{
// -------------------------------------------------------------------------------------------
// 1. WithClientHeader writes namespaced key with valid value
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeader_WritesNamespacedKey_WithValidValue()
{
// Arrange
var options = new ChatOptions();
// Act
options.WithClientHeader("x-client-end-user-id", "alice");
// Assert
Assert.NotNull(options.AdditionalProperties);
var raw = options.AdditionalProperties[ClientHeadersExtensions.ClientHeadersKey];
var dict = Assert.IsType<Dictionary<string, string>>(raw);
Assert.Equal("alice", dict["X-CLIENT-END-USER-ID"]); // OrdinalIgnoreCase
}
// -------------------------------------------------------------------------------------------
// 2. WithClientHeader rejects non-x-client- prefix
// -------------------------------------------------------------------------------------------
[Theory]
[InlineData("Authorization")]
[InlineData("X-Custom-Header")]
[InlineData("client-end-user-id")]
[InlineData("xclient-end-user-id")]
public void WithClientHeader_RejectsInvalidPrefix(string name)
{
// Arrange
var options = new ChatOptions();
// Act / Assert
Assert.Throws<ArgumentException>(() => options.WithClientHeader(name, "value"));
}
// -------------------------------------------------------------------------------------------
// 3. WithClientHeader rejects null/empty name and value
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeader_RejectsNullName()
{
var options = new ChatOptions();
Assert.Throws<ArgumentNullException>(() => options.WithClientHeader(null!, "v"));
}
[Fact]
public void WithClientHeader_RejectsNullValue()
{
var options = new ChatOptions();
Assert.Throws<ArgumentNullException>(() => options.WithClientHeader("x-client-foo", null!));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void WithClientHeader_RejectsEmptyOrWhitespaceName(string name)
{
var options = new ChatOptions();
Assert.Throws<ArgumentException>(() => options.WithClientHeader(name, "v"));
}
[Fact]
public void WithClientHeader_RejectsEmptyValue()
{
var options = new ChatOptions();
Assert.Throws<ArgumentException>(() => options.WithClientHeader("x-client-foo", ""));
}
// -------------------------------------------------------------------------------------------
// 4. WithClientHeaders (bulk) is all-or-nothing on first invalid key
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeaders_AllOrNothing_OnInvalidKey()
{
// Arrange
var options = new ChatOptions();
var headers = new[]
{
new KeyValuePair<string, string>("x-client-end-user-id", "alice"),
new KeyValuePair<string, string>("Authorization", "secret"), // invalid prefix
new KeyValuePair<string, string>("x-client-end-chat-id", "chat-1"),
};
// Act / Assert: throws, and no entries are written.
Assert.Throws<ArgumentException>(() => options.WithClientHeaders(headers));
Assert.Null(options.GetClientHeaders());
}
// -------------------------------------------------------------------------------------------
// 5. Multiple WithClientHeader calls accumulate (additive)
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeader_Accumulates_MultipleCalls()
{
// Arrange
var options = new ChatOptions();
// Act
options.WithClientHeader("x-client-a", "1");
options.WithClientHeader("x-client-b", "2");
options.WithClientHeader("x-client-a", "1-updated"); // upsert
// Assert
var dict = options.GetClientHeaders();
Assert.NotNull(dict);
Assert.Equal(2, dict!.Count);
Assert.Equal("1-updated", dict["x-client-a"]);
Assert.Equal("2", dict["x-client-b"]);
}
// -------------------------------------------------------------------------------------------
// 6. Conflict on slot occupied by foreign type throws InvalidOperationException
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeader_ForeignTypeAtSlot_Throws()
{
// Arrange
var options = new ChatOptions
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
[ClientHeadersExtensions.ClientHeadersKey] = "this is not a dictionary",
},
};
// Act / Assert
Assert.Throws<InvalidOperationException>(() => options.WithClientHeader("x-client-foo", "v"));
}
// -------------------------------------------------------------------------------------------
// 7. UseClientHeaders is idempotent (already-wired returns innerAgent)
// -------------------------------------------------------------------------------------------
[Fact]
public void UseClientHeaders_IsIdempotent()
{
// Arrange
var inner = new FakeAgent();
var first = inner.AsBuilder().UseClientHeaders().Build();
// Act
var second = first.AsBuilder().UseClientHeaders().Build();
// Assert: only one ClientHeadersAgent in the chain.
Assert.NotNull(first.GetService<ClientHeadersAgent>());
Assert.NotNull(second.GetService<ClientHeadersAgent>());
// The second call should return the same agent unchanged because the chain is already wired.
Assert.Same(first, second);
}
// -------------------------------------------------------------------------------------------
// 8. ClientHeadersAgent snapshots dict at push time (mid-run mutation does not leak)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersAgent_SnapshotsAtPush_MidRunMutationDoesNotLeakAsync()
{
// Arrange: a fake inner agent that exposes ClientHeadersScope.Current at the moment of RunAsync.
IReadOnlyDictionary<string, string>? observed = null;
var inner = new ProbeAgent(_ =>
{
observed = ClientHeadersScope.Current;
// Mutate the source dictionary mid-run; snapshot must not see the mutation.
return Task.CompletedTask;
});
var agent = new ClientHeadersAgent(inner);
var chatOptions = new ChatOptions();
chatOptions.WithClientHeader("x-client-end-user-id", "alice");
// Act
var task = agent.RunAsync(messages: [], options: new ChatClientAgentRunOptions(chatOptions));
// Mutate the source after RunAsync starts.
chatOptions.WithClientHeader("x-client-end-user-id", "bob");
await task;
// Assert: probe saw "alice", not "bob".
Assert.NotNull(observed);
Assert.Equal("alice", observed!["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 9. ClientHeadersAgent streaming keeps scope alive across yields
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersAgent_Streaming_HasScopeAtFirstYieldAsync()
{
// Arrange: in production the SCM pipeline policy fires once at the first MoveNextAsync
// (when MEAI's OpenAIResponsesChatClient initiates the HTTP request). We assert that at
// that critical moment the AsyncLocal scope is observable. End-to-end coverage of the wire
// behavior is provided by EndToEnd_UseClientHeaders_Streaming_StampsOnWireAsync.
IReadOnlyDictionary<string, string>? observedAtFirstYield = null;
var inner = new ProbeStreamingAgent(yields: 1, onYield: () => observedAtFirstYield = ClientHeadersScope.Current);
var agent = new ClientHeadersAgent(inner);
var chatOptions = new ChatOptions();
chatOptions.WithClientHeader("x-client-end-user-id", "carol");
// Act
await foreach (var _ in agent.RunStreamingAsync(messages: [], options: new ChatClientAgentRunOptions(chatOptions)))
{
// drain
}
// Assert
Assert.NotNull(observedAtFirstYield);
Assert.Equal("carol", observedAtFirstYield!["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 10. ClientHeadersScope.Push is LIFO and AsyncLocal-isolated (parallel runs don't leak)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersScope_IsLifoAndAsyncLocalIsolatedAsync()
{
// Arrange
var dictA = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
var dictB = new Dictionary<string, string> { ["x-client-end-user-id"] = "bob" };
// Act / Assert
await Task.WhenAll(
ProbeAsync(dictA, "alice"),
ProbeAsync(dictB, "bob"));
async Task ProbeAsync(Dictionary<string, string> dict, string expected)
{
using (ClientHeadersScope.Push(dict))
{
await Task.Yield();
Assert.Equal(expected, ClientHeadersScope.Current!["x-client-end-user-id"]);
}
}
}
// -------------------------------------------------------------------------------------------
// 11. ClientHeadersPolicy no-ops when scope is null
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersPolicy_NoOps_WhenScopeIsNullAsync()
{
// Arrange
using var handler = new RecordingHandler();
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var pipeline = ClientPipeline.Create(
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(http) },
perCallPolicies: [ClientHeadersPolicy.Instance],
perTryPolicies: default,
beforeTransportPolicies: default);
// Act: no scope pushed
var msg = pipeline.CreateMessage();
msg.Request.Method = "GET";
msg.Request.Uri = new Uri("https://example.test/");
await pipeline.SendAsync(msg);
// Assert
Assert.DoesNotContain(handler.Headers, kv => kv.Key.StartsWith("x-client-", StringComparison.OrdinalIgnoreCase));
}
// -------------------------------------------------------------------------------------------
// 12. ClientHeadersPolicy stamps with Set (overwrites pre-existing same-name header)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersPolicy_StampsWithSet_OverwritesPreExistingHeaderAsync()
{
// Arrange
using var handler = new RecordingHandler();
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
// A pre-existing policy that always sets x-client-end-user-id=initial.
var preExisting = new HeaderSetterPolicy("x-client-end-user-id", "initial");
var pipeline = ClientPipeline.Create(
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(http) },
perCallPolicies: [preExisting, ClientHeadersPolicy.Instance],
perTryPolicies: default,
beforeTransportPolicies: default);
var perCall = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
// Act
using (ClientHeadersScope.Push(perCall))
{
var msg = pipeline.CreateMessage();
msg.Request.Method = "GET";
msg.Request.Uri = new Uri("https://example.test/");
await pipeline.SendAsync(msg);
}
// Assert: the per-call value won.
Assert.Equal("alice", handler.Headers["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 13. Reflection dedup catches duplicate registration on a single OpenAIRequestPolicies
// -------------------------------------------------------------------------------------------
[Fact]
public void OpenAIRequestPoliciesReflection_DedupsDuplicateRegistration()
{
// Arrange
var policies = new OpenAIRequestPolicies();
// Act
var firstAdded = OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, ClientHeadersPolicy.Instance);
var secondAdded = OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, ClientHeadersPolicy.Instance);
// Assert
Assert.True(firstAdded);
Assert.False(secondAdded);
Assert.Equal(1, EntriesCount(policies));
}
// -------------------------------------------------------------------------------------------
// 14. Reflection dedup gracefully fails when shape is wrong (use a fake type to simulate)
// -------------------------------------------------------------------------------------------
[Fact]
public void OpenAIRequestPoliciesReflection_ContainsPolicy_ReturnsFalse_OnNullEntries()
{
// Arrange: a fresh OpenAIRequestPolicies (Entries field exists, but is empty).
var policies = new OpenAIRequestPolicies();
// Act / Assert
Assert.False(OpenAIRequestPoliciesReflection.ContainsPolicy(policies, ClientHeadersPolicy.Instance));
}
// -------------------------------------------------------------------------------------------
// 15. CI guardrail: assert OpenAIRequestPolicies._entries field shape
// -------------------------------------------------------------------------------------------
[Fact]
public void OpenAIRequestPolicies_EntriesField_ShapeGuardrail()
{
// Arrange / Act
var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
// Assert: this test fails loudly if MEAI renames the field, so we know to update
// OpenAIRequestPoliciesReflection. The Entry array element type is private so we only
// assert that the field is an Array; the ContainsPolicy method itself reflects the Policy
// member dynamically so it survives Entry-shape changes too.
Assert.NotNull(field);
Assert.True(typeof(Array).IsAssignableFrom(field!.FieldType),
$"Expected _entries to be an Array, got {field.FieldType}.");
}
// -------------------------------------------------------------------------------------------
// 16. Foundry hosting end-to-end: per-call x-client-end-user-id reaches the wire
// (Covered by the existing HostedOutboundUserAgentTests pattern; we add a focused unit test
// here that verifies UseClientHeaders + the OpenAIRequestPolicies bridge stamps headers
// on the wire when invoked through a real ChatClientAgent.)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task EndToEnd_UseClientHeaders_StampsOnWireAsync()
{
// Arrange: build a real OpenAI ResponsesClient pointed at a fake handler.
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
var responsesClient = openAIClient.GetResponsesClient();
IChatClient chatClient = responsesClient.AsIChatClient();
AIAgent agent = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
// Act
await agent.RunAsync("hi", options: runOptions);
// Assert
Assert.True(handler.Requests.Count > 0);
Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 17. Customer raw end-to-end: covered by #16 (which uses raw new ChatClientAgent + AsBuilder).
// Add a streaming variant here.
// -------------------------------------------------------------------------------------------
[Fact]
public async Task EndToEnd_UseClientHeaders_Streaming_StampsOnWireAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
var responsesClient = openAIClient.GetResponsesClient();
IChatClient chatClient = responsesClient.AsIChatClient();
AIAgent agent = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "carol");
// Act
try
{
await foreach (var _ in agent.RunStreamingAsync("hi", options: runOptions))
{
// drain
}
}
catch
{
// The fake handler returns a non-streaming JSON; MEAI may throw mid-stream while parsing.
// The wire request is captured before parsing, so the assertion below still validates the header.
}
// Assert
Assert.True(handler.Requests.Count > 0);
Assert.Equal("carol", handler.Requests[0].Headers["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 18. Headers-set-but-no-bridge: silent no-op confirmed (non-OpenAI mock)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task UseClientHeaders_OnNonOpenAIClient_IsSilentNoOpAsync()
{
// Arrange: a non-OpenAI fake agent that does not expose OpenAIRequestPolicies.
var inner = new FakeAgent();
var agent = inner.AsBuilder().UseClientHeaders().Build();
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
// Act / Assert: no throw. AsyncLocal flows but no policy stamps anything because the
// chat client doesn't have OpenAIRequestPolicies registered.
await agent.RunAsync("hi", options: runOptions);
Assert.True(true);
}
// -------------------------------------------------------------------------------------------
// 19. Shared IChatClient across two agents both calling UseClientHeaders registers
// ClientHeadersPolicy exactly once on the shared OpenAIRequestPolicies.
// -------------------------------------------------------------------------------------------
[Fact]
public async Task SharedChatClient_AcrossTwoAgents_RegistersPolicyOnceAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
var responsesClient = openAIClient.GetResponsesClient();
IChatClient chatClient = responsesClient.AsIChatClient();
// Act: build two agents that share the same chat client. Each calls UseClientHeaders.
AIAgent agent1 = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
AIAgent agent2 = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
// Assert: the shared OpenAIRequestPolicies has exactly one ClientHeadersPolicy registered.
var policies = chatClient.GetService<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
// And on the wire, the per-call header is stamped exactly once (no duplication).
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
try
{
await agent1.RunAsync("hi", options: runOptions);
}
catch
{
// tolerate parser issues; we assert on the wire.
}
Assert.True(handler.Requests.Count > 0);
Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 20. ClientHeadersPolicy registration via UseClientHeaders is deduped across many invocations
// on the same chat client (mirrors the Foundry.Hosting per-request resolution scenario).
// -------------------------------------------------------------------------------------------
[Fact]
public void UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce()
{
// Arrange: a chat client whose OpenAIRequestPolicies service we can inspect.
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"),
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
// Act: simulate N hosted-resolution-style wirings on top of the same shared chat client.
for (int i = 0; i < 25; i++)
{
_ = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
}
// Assert: exactly one ClientHeadersPolicy entry on the shared OpenAIRequestPolicies.
var policies = chatClient.GetService<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
}
// -------------------------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------------------------
private static int EntriesCount(OpenAIRequestPolicies policies)
{
var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
var array = (Array?)field?.GetValue(policies);
return array?.Length ?? -1;
}
private static string MinimalResponseJson() => """
{
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}
""";
/// <summary>An <see cref="HttpClientHandler"/> that records request headers and returns a fixed response body.</summary>
private sealed class RecordingHandler : HttpClientHandler
{
private readonly string _body;
public RecordingHandler(string body = """{}""")
{
this._body = body;
}
public List<RecordedRequest> Requests { get; } = [];
public Dictionary<string, string> Headers => this.Requests.Count > 0 ? this.Requests[0].Headers : new Dictionary<string, string>();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var h in request.Headers)
{
headers[h.Key] = string.Join(",", h.Value);
}
this.Requests.Add(new RecordedRequest(request.RequestUri?.ToString() ?? "?", headers));
var resp = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
RequestMessage = request,
};
return Task.FromResult(resp);
}
}
private sealed class RecordedRequest
{
public RecordedRequest(string uri, Dictionary<string, string> headers)
{
this.Uri = uri;
this.Headers = headers;
}
public string Uri { get; }
public Dictionary<string, string> Headers { get; }
}
/// <summary>A pipeline policy that always stamps a fixed header value via Headers.Set.</summary>
private sealed class HeaderSetterPolicy : PipelinePolicy
{
private readonly string _name;
private readonly string _value;
public HeaderSetterPolicy(string name, string value)
{
this._name = name;
this._value = value;
}
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
/// <summary>A trivial session used by fake agents in these tests.</summary>
private sealed class TrivialSession : AgentSession { }
/// <summary>A minimal AIAgent that does nothing; used to test decorator wiring.</summary>
private sealed class FakeAgent : AIAgent
{
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentResponse());
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield break;
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new TrivialSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(new TrivialSession());
}
/// <summary>An AIAgent that invokes a probe action each time RunAsync is called.</summary>
private sealed class ProbeAgent : AIAgent
{
private readonly Func<CancellationToken, Task> _probe;
public ProbeAgent(Func<CancellationToken, Task> probe)
{
this._probe = probe;
}
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
await this._probe(cancellationToken);
return new AgentResponse();
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await this._probe(cancellationToken);
yield break;
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new TrivialSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(new TrivialSession());
}
/// <summary>An AIAgent whose streaming method invokes <c>onYield</c> at each yield point.</summary>
private sealed class ProbeStreamingAgent : AIAgent
{
private readonly int _yields;
private readonly Action _onYield;
public ProbeStreamingAgent(int yields, Action onYield)
{
this._yields = yields;
this._onYield = onYield;
}
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentResponse());
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
for (int i = 0; i < this._yields; i++)
{
this._onYield();
await Task.Yield();
yield return new AgentResponseUpdate();
}
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new TrivialSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(new TrivialSession());
}
}
@@ -5,6 +5,7 @@ using System.ClientModel.Primitives;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
@@ -153,6 +154,48 @@ public class FoundryAgentTests
Assert.NotNull(innerAgent);
}
[Fact]
public void Constructor_PreWiresClientHeadersAgent()
{
// Arrange / Act: the public FoundryAgent ctor should pre-wire the client-headers
// pipeline so x-client-* headers stamped on ChatClientAgentRunOptions reach the wire.
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
// Assert: ClientHeadersAgent decorator is present in the delegating chain.
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
}
[Fact]
public void Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent()
{
// Arrange: stand up a real AIProjectClient pointed at a fake transport.
using var handler = new NoopHandler();
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) });
// Act: this AsAIAgent path constructs FoundryAgent via its internal
// (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring.
var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name"));
// Assert
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
}
private sealed class NoopHandler : HttpClientHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
[Fact]
public void GetService_ReturnsIChatClient()
{
@@ -18,6 +18,7 @@
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="FoundryEvalConverterTests.cs" />
<Compile Remove="FoundryEvalsTests.cs" />
<Compile Remove="ClientHeadersExtensionsTests.cs" />
</ItemGroup>
<ItemGroup>
@@ -14,7 +14,7 @@ public class GitHubCopilotAgentTests
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
private static Task<PermissionRequestResult> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
=> Task.FromResult(new PermissionRequestResult { Kind = "approved" });
=> Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });
[Fact(Skip = SkipReason)]
public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync()
@@ -201,11 +201,10 @@ public class GitHubCopilotAgentTests
SessionConfig sessionConfig = new()
{
OnPermissionRequest = OnPermissionRequestAsync,
McpServers = new Dictionary<string, object>
McpServers = new Dictionary<string, McpServerConfig>
{
["filesystem"] = new McpLocalServerConfig
["filesystem"] = new McpStdioServerConfig
{
Type = "stdio",
Command = "npx",
Args = ["-y", "@modelcontextprotocol/server-filesystem", "."],
Tools = ["*"],
@@ -234,11 +233,10 @@ public class GitHubCopilotAgentTests
SessionConfig sessionConfig = new()
{
OnPermissionRequest = OnPermissionRequestAsync,
McpServers = new Dictionary<string, object>
McpServers = new Dictionary<string, McpServerConfig>
{
["microsoft-learn"] = new McpRemoteServerConfig
["microsoft-learn"] = new McpHttpServerConfig
{
Type = "http",
Url = "https://learn.microsoft.com/api/mcp",
Tools = ["*"],
},
@@ -111,7 +111,7 @@ public sealed class GitHubCopilotAgentTests
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
var mcpServers = new Dictionary<string, object> { ["server1"] = new McpLocalServerConfig() };
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
var source = new SessionConfig
{
@@ -162,7 +162,7 @@ public sealed class GitHubCopilotAgentTests
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
var mcpServers = new Dictionary<string, object> { ["server1"] = new McpLocalServerConfig() };
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
var source = new SessionConfig
{
@@ -69,6 +69,69 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.Equal("A quoted description", skills[0].Frontmatter.Description);
}
[Fact]
public async Task GetSkillsAsync_BlockScalarDescription_ParsesMultilineValueAsync()
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "block-scalar-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: block-scalar-skill\ndescription: |\n This is a multiline\n description for the skill.\n---\nBody text.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert
Assert.Single(skills);
Assert.Equal("This is a multiline\ndescription for the skill.", skills[0].Frontmatter.Description);
}
[Fact]
public async Task GetSkillsAsync_FoldedScalarDescription_ParsesMultilineValueAsync()
{
// Arrange
string skillDir = Path.Combine(this._testRoot, "folded-scalar-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: folded-scalar-skill\ndescription: >\n This is a multiline\n description for the skill.\n---\nBody text.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert
Assert.Single(skills);
Assert.Equal("This is a multiline description for the skill.", skills[0].Frontmatter.Description);
}
[Theory]
[InlineData("|-", "This is a multiline\ndescription for the skill.")]
[InlineData("|+", "This is a multiline\ndescription for the skill.\n")]
[InlineData(">-", "This is a multiline description for the skill.")]
[InlineData(">+", "This is a multiline description for the skill.\n")]
public async Task GetSkillsAsync_ScalarDescriptionWithChompingIndicator_ParsesValueAsync(string indicator, string expectedDescription)
{
// Arrange
string chomping = indicator[1] == '+' ? "keep" : "strip";
string skillName = "chomping-scalar-skill-" + (indicator[0] == '|' ? "literal-" : "folded-") + chomping;
string skillDir = Path.Combine(this._testRoot, skillName);
Directory.CreateDirectory(skillDir);
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
$"---\nname: {skillName}\ndescription: {indicator}\n This is a multiline\n description for the skill.\n---\nBody text.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert
Assert.Single(skills);
Assert.Equal(expectedDescription, skills[0].Frontmatter.Description);
}
[Fact]
public async Task GetSkillsAsync_MissingFrontmatter_ExcludesSkillAsync()
{
@@ -328,7 +328,7 @@ public class TodoProviderTests
#region Public Helper Method Tests
/// <summary>
/// Verify that GetAllTodos returns all items after adding via tools.
/// Verify that GetAllTodosAsync returns all items after adding via tools.
/// </summary>
[Fact]
public async Task PublicGetAllTodos_ReturnsAllItemsAsync()
@@ -348,7 +348,7 @@ public class TodoProviderTests
});
// Act
var todos = provider.GetAllTodos(session);
var todos = await provider.GetAllTodosAsync(session);
// Assert
Assert.Equal(2, todos.Count);
@@ -357,7 +357,7 @@ public class TodoProviderTests
}
/// <summary>
/// Verify that GetRemainingTodos returns only incomplete items.
/// Verify that GetRemainingTodosAsync returns only incomplete items.
/// </summary>
[Fact]
public async Task PublicGetRemainingTodos_ReturnsOnlyIncompleteAsync()
@@ -379,7 +379,7 @@ public class TodoProviderTests
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
// Act
var remaining = provider.GetRemainingTodos(session);
var remaining = await provider.GetRemainingTodosAsync(session);
// Assert
Assert.Single(remaining);
@@ -387,17 +387,17 @@ public class TodoProviderTests
}
/// <summary>
/// Verify that GetAllTodos returns empty list for a new session.
/// Verify that GetAllTodosAsync returns empty list for a new session.
/// </summary>
[Fact]
public void PublicGetAllTodos_ReturnsEmptyForNewSession()
public async Task PublicGetAllTodos_ReturnsEmptyForNewSessionAsync()
{
// Arrange
var provider = new TodoProvider();
var session = new ChatClientAgentSession();
// Act
var todos = provider.GetAllTodos(session);
var todos = await provider.GetAllTodosAsync(session);
// Assert
Assert.Empty(todos);
@@ -489,4 +489,293 @@ public class TodoProviderTests
}
#endregion
#region Message Injection Tests
/// <summary>
/// Verify that ProvideAIContextAsync injects a "none yet" message when the list is empty.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_InjectsEmptyTodoMessageAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Messages);
var messages = result.Messages!.ToList();
Assert.Single(messages);
Assert.Contains("none yet", messages[0].Text);
Assert.Contains("### Current todo list", messages[0].Text);
}
/// <summary>
/// Verify that ProvideAIContextAsync injects a message listing existing todos with status.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_InjectsTodoListMessageAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// First invocation — add some todos (one with a description to cover that branch)
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Complete");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput>
{
new() { Title = "First" },
new() { Title = "Second", Description = "Has details" },
},
});
await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1 } });
// Act — second invocation should see the updated list in messages
AIContext result2 = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result2.Messages);
var messages = result2.Messages!.ToList();
Assert.Single(messages);
string text = messages[0].Text!;
Assert.Contains("### Current todo list", text);
Assert.Contains("[done] First", text);
Assert.Contains("[open] Second", text);
Assert.Contains(": Has details", text);
}
/// <summary>
/// Verify that when SuppressTodoListMessage is true, no message is injected.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_SuppressTodoListMessage_NoMessageInjectedAsync()
{
// Arrange
var provider = new TodoProvider(new TodoProviderOptions { SuppressTodoListMessage = true });
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Null(result.Messages);
}
/// <summary>
/// Verify that a custom TodoListMessageBuilder is used when provided.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_CustomTodoListMessageBuilder_UsesCustomFormatterAsync()
{
// Arrange
var provider = new TodoProvider(new TodoProviderOptions
{
TodoListMessageBuilder = items => $"Custom: {items.Count} items",
});
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// First invocation — add a todo
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Task A" } },
});
// Act — second invocation should use the custom builder
AIContext result2 = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result2.Messages);
var messages = result2.Messages!.ToList();
Assert.Single(messages);
Assert.Equal("Custom: 1 items", messages[0].Text);
}
/// <summary>
/// Verify that SuppressTodoListMessage takes precedence over a set TodoListMessageBuilder.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_SuppressWinsOverBuilder_NoMessageInjectedAsync()
{
// Arrange
var provider = new TodoProvider(new TodoProviderOptions
{
SuppressTodoListMessage = true,
TodoListMessageBuilder = items => "Should not appear",
});
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.Null(result.Messages);
}
/// <summary>
/// Verify that the list passed to TodoListMessageBuilder is a snapshot and mutating it does not affect state.
/// </summary>
[Fact]
public async Task ProvideAIContextAsync_BuilderReceivesSnapshot_MutationDoesNotAffectStateAsync()
{
// Arrange
IReadOnlyList<TodoItem>? capturedList = null;
var provider = new TodoProvider(new TodoProviderOptions
{
TodoListMessageBuilder = items =>
{
capturedList = items;
return "snapshot test";
},
});
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
// Add a todo
AIContext result1 = await provider.InvokingAsync(context);
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "Original" } },
});
// Act — invoke again to trigger builder with 1 item
await provider.InvokingAsync(context);
// Mutate the captured snapshot
Assert.NotNull(capturedList);
var mutableList = (List<TodoItem>)capturedList!;
mutableList.Clear();
// Assert — provider state is unaffected
var allTodos = await provider.GetAllTodosAsync(session);
Assert.Single(allTodos);
Assert.Equal("Original", allTodos[0].Title);
}
#endregion
#region Concurrency Tests
/// <summary>
/// Verify that concurrent add operations do not produce duplicate IDs.
/// </summary>
[Fact]
public async Task ConcurrentAdds_ProduceUniqueIdsAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
// Act — launch multiple concurrent adds
var tasks = Enumerable.Range(0, 10).Select(i =>
addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = $"Item {i}" } },
}).AsTask());
await Task.WhenAll(tasks);
// Assert — all IDs are unique and sequential
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
var all = GetArrayResult(allResult);
Assert.Equal(10, all.Count);
#pragma warning disable RCS1077 // Optimize LINQ method call — .Order() not available on net472
var ids = all.Select(e => e.GetProperty("id").GetInt32()).OrderBy(x => x).ToList();
#pragma warning restore RCS1077
Assert.Equal(Enumerable.Range(1, 10).ToList(), ids);
}
/// <summary>
/// Verify that concurrent add and complete operations serialize correctly.
/// </summary>
[Fact]
public async Task ConcurrentAddAndComplete_SerializesCorrectlyAsync()
{
// Arrange
var provider = new TodoProvider();
var agent = new Mock<AIAgent>().Object;
var session = new ChatClientAgentSession();
#pragma warning disable MAAI001
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
#pragma warning restore MAAI001
AIContext result = await provider.InvokingAsync(context);
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
// Add initial items
await addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput>
{
new() { Title = "Existing 1" },
new() { Title = "Existing 2" },
new() { Title = "Existing 3" },
},
});
// Act — concurrent adds and completions
await Task.WhenAll(
addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "New A" }, new() { Title = "New B" } },
}).AsTask(),
addTodos.InvokeAsync(new AIFunctionArguments()
{
["todos"] = new List<TodoItemInput> { new() { Title = "New C" } },
}).AsTask(),
completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 1, 2, 3 } }).AsTask());
// Assert
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
var all = GetArrayResult(allResult);
Assert.Equal(6, all.Count);
#pragma warning disable RCS1077 // Optimize LINQ method call — .Order() not available on net472
var ids = all.Select(e => e.GetProperty("id").GetInt32()).OrderBy(x => x).ToList();
#pragma warning restore RCS1077
Assert.Equal(ids.Count, ids.Distinct().Count()); // no duplicates
Assert.Equal(Enumerable.Range(1, 6).ToList(), ids);
var completedIds = all.Where(e => e.GetProperty("isComplete").GetBoolean()).Select(e => e.GetProperty("id").GetInt32()).ToHashSet();
Assert.Subset(new HashSet<int> { 1, 2, 3 }, completedIds);
}
#endregion
}
@@ -577,7 +577,8 @@ public class OpenTelemetryAgentTests
}
},
{
"type": "web_search"
"type": "web_search",
"name": "web_search"
},
{
"type": "function",
@@ -604,43 +605,21 @@ public class OpenTelemetryAgentTests
Assert.False(tags.ContainsKey("gen_ai.output.messages"));
Assert.False(tags.ContainsKey("gen_ai.system_instructions"));
// gen_ai.tool.definitions is always emitted regardless of EnableSensitiveData (ME.AI 10.4.0+)
// gen_ai.tool.definitions is always emitted regardless of EnableSensitiveData (ME.AI 10.4.0+).
// ME.AI 10.5.1 omits description/parameters for function tools when sensitive data is disabled.
Assert.Equal(ReplaceWhitespace("""
[
{
"type": "function",
"name": "GetPersonAge",
"description": "Gets the age of a person by name.",
"parameters": {
"type": "object",
"properties": {
"personName": {
"type": "string"
}
},
"required": [
"personName"
]
}
"name": "GetPersonAge"
},
{
"type": "web_search"
"type": "web_search",
"name": "web_search"
},
{
"type": "function",
"name": "GetCurrentWeather",
"description": "Gets the current weather for a location.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string"
}
},
"required": [
"location"
]
}
"name": "GetCurrentWeather"
}
]
"""), ReplaceWhitespace(tags["gen_ai.tool.definitions"]));
@@ -33,7 +33,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) =>
this.RunWorkflowAsync(GetWorkflowPath(workflowFileName, isSample: true), testcaseFileName, externalConveration);
[Theory(Skip = "Multi-turn tests hang in CI - needs investigation")]
[Theory]
[InlineData("ConfirmInput.yaml", "ConfirmInput.json", false)]
[InlineData("RequestExternalInput.yaml", "RequestExternalInput.json", false)]
public Task ValidateMultiTurnAsync(string workflowFileName, string testcaseFileName, bool isSample) =>
@@ -4,14 +4,19 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
@@ -48,9 +53,9 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati
#region InvokeHttpRequest Tests
[RetryTheory(3, 5000)]
[InlineData("HttpRequest.yaml", "visibility: public")]
public Task ValidateHttpRequestAsync(string workflowFileName, string? expectedResultContains) =>
this.RunHttpRequestTestAsync(workflowFileName, expectedResultContains);
[InlineData("HttpRequest.yaml")]
public Task ValidateHttpRequestAsync(string workflowFileName) =>
this.RunHttpRequestTestAsync(workflowFileName);
#endregion
@@ -261,16 +266,65 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati
#region InvokeHttpRequest Test Helpers
/// <summary>
/// The Azure ARM scope used to acquire bearer tokens for the HttpRequestAction
/// integration test. Matches the URL configured in <c>HttpRequest.yaml</c>.
/// </summary>
private const string ArmScope = "https://management.azure.com/.default";
/// <summary>
/// The expected ARM endpoint. Only requests whose absolute URL exactly matches
/// this scheme and host receive the authenticated <see cref="HttpClient"/>; all
/// other URLs (including subdomain look-alikes such as
/// <c>https://management.azure.com.evil.com</c>) fall through to the handler
/// default and never see the bearer token.
/// </summary>
private static readonly Uri s_armEndpoint = new("https://management.azure.com/");
/// <summary>
/// Runs an HttpRequestAction workflow test with the specified configuration.
/// </summary>
/// <remarks>
/// The workflow under test calls an authenticated Azure ARM endpoint. We acquire a
/// single bearer token via the same Azure CLI credential used elsewhere in the
/// integration test suite, attach it to a cached <see cref="HttpClient"/>, and route
/// matching requests through that client via <see cref="DefaultHttpRequestHandler"/>'s
/// <c>httpClientProvider</c> callback. The test owns the <see cref="HttpClient"/>'s
/// lifetime and disposes it explicitly — <see cref="DefaultHttpRequestHandler"/> does
/// not dispose provider-returned clients.
/// </remarks>
private async Task RunHttpRequestTestAsync(
string workflowFileName,
string? expectedResultContains = null)
string workflowFileName)
{
// Arrange
string workflowPath = GetWorkflowPath(workflowFileName);
await using DefaultHttpRequestHandler httpRequestHandler = new();
AccessToken accessToken =
await TestAzureCliCredentials
.CreateAzureCliCredential()
.GetTokenAsync(new TokenRequestContext([ArmScope]), CancellationToken.None)
.ConfigureAwait(false);
using HttpClient authenticatedClient = new();
authenticatedClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken.Token);
await using DefaultHttpRequestHandler httpRequestHandler =
new(httpClientProvider: (request, _) =>
{
if (Uri.TryCreate(request.Url, UriKind.Absolute, out Uri? requestUri) &&
string.Equals(requestUri.Scheme, s_armEndpoint.Scheme, StringComparison.OrdinalIgnoreCase) &&
string.Equals(requestUri.Host, s_armEndpoint.Host, StringComparison.OrdinalIgnoreCase))
{
#pragma warning disable CA2025 // authenticatedClient outlives the handler (LIFO using disposal) and the workflow awaits all dispatches.
return Task.FromResult<HttpClient?>(authenticatedClient);
#pragma warning restore CA2025
}
// Fall back to the handler's internal client for any non-ARM URLs.
return Task.FromResult<HttpClient?>(null);
});
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(
externalConversation: false,
httpRequestHandler: httpRequestHandler);
@@ -284,11 +338,16 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati
// Assert - Verify executor and action events
AssertWorkflowEventsEmitted(workflowEvents);
// Assert - Verify expected result if specified
if (expectedResultContains is not null)
{
AssertResultContains(workflowEvents, expectedResultContains);
}
MessageActivityEvent? messageEvent = workflowEvents.Events
.OfType<MessageActivityEvent>()
.LastOrDefault();
Assert.NotNull(messageEvent);
Assert.NotNull(messageEvent.Message);
Assert.True(
Guid.TryParse(messageEvent.Message, out Guid retrievedTenantId),
$"Expected the SendMessage payload to be a tenant GUID, but got: '{messageEvent.Message}'");
Assert.NotEqual(Guid.Empty, retrievedTenantId);
}
#endregion
@@ -1,11 +1,15 @@
{
"description": "Human in the loop sample - RequestExternalInput.yaml.",
"description": "Human in the loop sample - ConfirmInput.yaml. First response mismatches to exercise the GotoAction re-prompt path; second response matches and completes the workflow.",
"setup": {
"input": {
"type": "String",
"value": "1234"
},
"responses": [
{
"type": "String",
"value": "9999"
},
{
"type": "String",
"value": "1234"
@@ -14,12 +18,21 @@
},
"validation": {
"conversation_count": 1,
"min_action_count": 4,
"min_action_count": 6,
"max_action_count": -1,
"min_response_count": 0,
"min_response_count": 2,
"max_response_count": -1,
"min_message_count": 0,
"max_message_count": -1,
"actions": {
"start": [
"set_project"
"set_project",
"question_confirm"
],
"repeat": [
"sendActivity_mismatch",
"goto_again",
"question_confirm"
],
"final": [
"sendActivity_confirmed"

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