Compare commits

...
Author SHA1 Message Date
alliscodeandCopilot 27bbfefb42 Address PR feedback: JsonElement special-case, symmetric inbound unwrap, tests
OutputConverter: extract EncodeFunctionResultAsJsonStringPayload helper
that special-cases JsonElement / JsonDocument so a string-kind element
does not get double-encoded into "\"value\"". Other JsonElement kinds
(object/array/number/bool) round-trip via GetRawText() and are then
JSON-string-wrapped, matching the spec.

InputConverter: symmetric DecodeFunctionResultPayload added to
ConvertFunctionCallOutput and ConvertFunctionToolCallOutput so
previously-stored function_call_output items replayed via
previous_response_id unwrap back to the original tool result text
instead of leaking the JSON-encoded form into FunctionResultContent.Result.
Legacy non-conforming raw-JSON-value payloads pass through unchanged.

Tests:
  - Replace ConvertUpdatesToEventsAsync_FunctionResultStringPayload_EmittedAsRawTextAsync
    with EmittedAsJsonStringAsync asserting the new wire contract ("sunny" -> "\"sunny\"").
  - Add coverage for object payloads, JsonElement string kind (no double-encoding),
    and JsonElement array kind (JSON-stringified).
  - Add InputConverter round-trip tests for spec-compliant JSON-string payloads
    and legacy raw-JSON-array payloads.

All 663 tests pass on net8/net9/net10. Verified end-to-end against the local
hosted-harness sample: T1-T4 (incl. TodoList tool replay across turns) all
succeed with no SDK parse errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 15:13:07 -07:00
alliscode 412db30b72 Merge remote-tracking branch 'origin/main' into hosted-output-formating 2026-05-07 14:54:47 -07:00
3c1e2c40b8 Fix typo:sesionEleme -> sessionElement (#5674)
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-07 21:05:41 +00:00
alliscodeandCopilot 5791203d77 Fix function_call_output.output to be a JSON string on the wire
OutputConverter was passing the JSON serialization of complex tool results (e.g. List<TodoItem>) directly into OutputItemFunctionToolCallOutput via BinaryData.FromString. The Responses SDK treats that BinaryData as the *raw JSON value* for the field, so non-string results landed on the wire as an unquoted JSON array (e.g. `"output":[{...}]`) instead of a JSON string.

The OpenAI Responses spec requires `function_call_output.output` to be a JSON string. The strict-parsing OpenAI .NET client (FunctionCallOutputResponseItem) consequently failed when threading a follow-up turn that replayed such an item, with: `The JSON value could not be converted... requires an element of type 'String', but the target element has type 'Array'`.

Always wrap the payload as a JSON string literal:

  - string s   -> JSON-encode s (quoted, with escapes)

  - object o   -> JSON-serialize o, then JSON-encode the resulting text

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 13:58:00 -07:00
d3518ad19d fix(security): non-thread-safe sequence number generation may cau (#5320)
`SequenceNumber.Increment()` uses `this._sequenceNumber++` without synchronization. In concurrent streaming scenarios, this can produce race conditions and inconsistent sequencing, which may break event ordering guarantees and potentially allow response-mixing or state confusion.

Affected files: SequenceNumber.cs

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-07 20:42:49 +00:00
c06af9a1b3 .NET: Python: Add dotnet integration test report to CI (#5515)
* Add dotnet integration test report to CI

- Add --report-junit flag to dotnet integration test step to generate
  JUnit XML alongside TRX, with explicit --results-directory to
  centralize output in IntegrationTestResults/
- Upload JUnit XML artifacts from each matrix leg (net10.0/ubuntu,
  net472/windows) as dotnet-test-results-{framework}-{os}
- Add dotnet-integration-test-report job that downloads artifacts,
  runs the existing aggregate.py script, posts markdown to Job Summary,
  and saves trend history via actions/cache
- Refactor aggregate.py to discover JUnit XML files recursively,
  supporting both pytest (pytest.xml) and xunit (*.junit.xml) layouts
- Handle provider name derivation for dotnet artifact naming convention
- Fix nodeid collision when same test runs under multiple frameworks
  by qualifying keys with provider when collisions are detected
- Improve module extraction for dotnet C# classnames (recognizes
  IntegrationTests/UnitTests namespace segments)

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

* chore: trigger dotnet CI for report validation

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

* fix: use .junit extension (not .junit.xml) for xunit v3 output

xUnit v3 generates files with .junit extension, not .junit.xml.
Update upload glob and aggregate.py discovery to match.

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

* fix: use deterministic provider-qualified keys for dotnet tests

Always prefix dotnet test keys with provider (e.g. net10.0 (ubuntu)::TestName)
to ensure stable, comparable counts across runs regardless of file parse order.
Also show Executed (passed+failed) instead of Total in summary table.

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

* fix: match Python report summary format (Total, passed/total, etc.)

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

* feat: split dotnet report into per-framework tables

Dotnet tests run on multiple frameworks (net10.0, net472). Instead of
one combined table with unstable totals, show separate sections per
framework — each with its own summary row and per-test table. Python
reports retain the original single-table format.

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

* Re-enable 7 flaky dotnet integration tests with increased timeouts

Increase timeouts to reduce timing-related flakiness in LLM-backed
integration tests (issue #4971):

- ExternalClientTests: 60s -> 120s default timeout
- SamplesValidationBase: 60s -> 120s default timeout
- ConsoleAppSamplesValidation: 90s -> 150s for long-running tests
- AzureFunctions SamplesValidation: 2min -> 3min orchestration timeout,
  60s -> 90s per-step WaitForConditionAsync timeouts

Remove all Skip=Flaky annotations and unused SkipFlakyTimingTest constants.

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

* Re-skip LLM non-determinism flaky tests, keep timeout fixes

Re-skip SingleAgentOrchestrationHITLSampleValidationAsync and
LongRunningToolsSampleValidationAsync - these fail due to LLM producing
extra review notifications, not timeouts. Updated skip reasons to
accurately describe the root cause. Reverted unnecessary timeout change
on the skipped LongRunningTools test.

The remaining 5 re-enabled tests with timeout increases are stable.

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

* Enable Anthropic integration tests in CI

Replace hardcoded skip with conditional skip pattern (matching
CopilotStudio approach): tests gracefully skip when ANTHROPIC_API_KEY
is missing, and run when present.

Changes:
- AnthropicChatCompletionFixture: try/catch in InitializeAsync with
  Assert.Skip on missing config (replaces hardcoded SkipReason)
- AnthropicSkillsIntegrationTests: same pattern per test method
- dotnet-build-and-test.yml: wire up ANTHROPIC_API_KEY,
  ANTHROPIC_CHAT_MODEL_NAME, and ANTHROPIC_REASONING_MODEL_NAME
  env vars to the integration test step

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

* Fix missing System using in AnthropicSkillsIntegrationTests

Add 'using System;' for InvalidOperationException in try/catch blocks.

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

* Skip flaky SingleAgentOrchestrationChainingSampleValidationAsync

LLM non-determinism causes Assert.NotNull failures on orchestration
results. Skip until test logic is hardened against non-deterministic
LLM responses.

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

* Re-enable HITL and LongRunningTools tests with timeout and flexibility fixes

- Remove Skip attribute from SingleAgentOrchestrationHITLSampleValidationAsync
- Remove Skip attribute from LongRunningToolsSampleValidationAsync
- Increase timeout from 120s/90s to 180s to accommodate 2+ LLM round-trips
- Replace rigid 2-cycle assertion with flexible approval logic that handles
  extra review cycles from LLM non-determinism

Fixes the two failure modes identified in #4971:
1. Timeout: 120s/90s was insufficient for multiple LLM calls under CI load
2. Extra notifications: Assert.Fail on 3rd+ review cycle was too rigid

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

* Increase AzureFunctions LongRunningTools test timeouts from 90s to 180s

The LongRunningToolsSampleValidationAsync test in the AzureFunctions integration
tests was failing in CI with TimeoutException at the 'Content published
notification is logged' step. The 90-second timeouts are too tight for CI
environments where LLM calls and orchestration overhead can be slow.

Increased all three WaitForConditionAsync timeouts from 90s to 180s:
- Waiting for human feedback notification
- Waiting for publish notification (the step that was failing)
- Waiting for orchestration completion

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

* Merge main and fix dotnet report path after flaky_report rename

Merge upstream/main which renamed scripts/flaky_report/ to
scripts/integration_test_report/ (from Python PR #5454). Update the
dotnet-build-and-test workflow to reference the new path.

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

* Add RetryFact to DurableTask and AzureFunctions integration tests

These tests interact with LLMs via stdin/stdout (DurableTask) or HTTP
(AzureFunctions) and are inherently non-deterministic. Unlike the Python
side which uses pytest-retry, the dotnet tests had no retry mechanism
and a single transient failure would fail the entire CI run.

Changes:
- Switch [Fact] to [RetryFact(2, 5000)] on all LLM-dependent tests
  across ConsoleAppSamplesValidation, ExternalClientTests,
  WorkflowConsoleAppSamplesValidation, and AzureFunctions SamplesValidation
- Add re-prompt mechanism to LongRunningToolsSampleValidationAsync:
  if the LLM doesn't invoke the tool within 60s, re-send the prompt
  (up to 2 retries) instead of burning the full timeout
- Reduce LongRunningTools timeout from 240s to 180s (re-prompt makes
  the extra buffer unnecessary)
- Leave simple/deterministic tests as [Fact] (SingleAgent, unit tests)

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

* Add persist-credentials: false to Integration Test Report checkout step

Matches the convention used by other checkout steps in this workflow
to avoid leaving GITHUB_TOKEN credentials in the local git config.

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

* small fixes

* disable anthropic failing tests

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 20:39:32 +00:00
1d94518f37 Python: Add ClassSkill for class-based skill definitions (#5678)
* Python: Add ClassSkill for class-based skill definitions

Add ClassSkill abstract base class with decorator-based resource and script
discovery, porting .NET's AgentClassSkill (PRs #5027 and #5183) to Python.

- Add ClassSkill(Skill, ABC) with instructions abstract property, cached
  content/resources/scripts properties
- Add @ClassSkill.resource and @ClassSkill.script static method decorators
  for auto-discovery of methods and properties
- Extract _build_skill_content() and _create_resource_element() shared
  helpers from InlineSkill for reuse
- Add _discover_marked_members() for scanning class hierarchies
- Add _make_method_name() for Python-to-skill name conversion
- Add class_based_skill sample (UnitConverterSkill)
- Update mixed_skills sample with TemperatureConverterSkill
- Add 58 new tests covering ClassSkill, decorator discovery, property
  resources, inheritance, kwargs forwarding, and duplicate detection
- Export ClassSkill from agent_framework public API

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

* fix: replace try/except/continue with assignment to satisfy bandit B112

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

* address PR review feedback

- Walk cls.__mro__ in _discover_marked_members for inherited property resources
- Use inspect.getattr_static for MRO-aware is_property check
- Return defensive copies from resources/scripts properties
- Raise TypeError on wrong decorator stacking order (@resource above @property)
- Log warning instead of silently swallowing descriptor errors during discovery
- Validate explicit name= at decoration time via _validate_member_name
- Add tests for all of the above

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

* Fix temperature converter skill: make resource necessary for script

Refactor TemperatureConverterSkill so the agent must read the
formulas resource (factor/offset) before calling the script,
aligning with the volume-converter pattern.

- Resource: numeric factor/offset table instead of symbolic formulas
- Script: generic linear transform (value * factor + offset)
- Instructions: updated to reflect new workflow

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 19:39:12 +00:00
Roger BarretoandGitHub a478d1b53c .NET: Foundry.Hosting IT: avoid MSB3026 in publish; fix telemetry UT flake (#5689)
CI publish step: gate the BuildProjectReferences=false fast-path on an explicit -UsePrebuiltProjectReferences switch (passed by the workflow) instead of marker detection. Adds a preflight error when stale obj/Release/net10.0 outputs would cause CS0579, with actionable recovery instructions.

Telemetry UT flake: AgentFrameworkResponseHandlerTelemetryTests was using a plain List<Activity> for OTel's InMemoryExporter. The exporter writes from background Activity completion callbacks while parallel tests on the same global ActivitySource feed every listener, racing against the assertion's enumeration and throwing 'Collection was modified'. Replaced with a small thread-safe ConcurrentActivityList that locks add/enumerate and returns a snapshot for assertions.
2026-05-07 18:54:46 +00:00
Jacob AlberandGitHub ce70ca1a9f .NET: feat: Implement Magentic Orchestration for .NET (#5595)
* feat: Implement Magentic Orchestration for .NET

* fixup: Update for review comments

* fix: Fix FenceJsonRegexPattern

* fix: Format

* fix: Updates for PR feedback

* fix: Add missing serialized types to source gen for trimming

* fix: Address PR Comments
2026-05-07 18:36:15 +00:00
2a9b68d1bd Python: Fix MCPStreamableHTTPTool leaking asyncio.CancelledError when MCP server is unreachable (#5687)
* fix: wrap asyncio.CancelledError in ToolException in _connect_on_owner (#5667)

asyncio.CancelledError is a BaseException (not Exception) in Python 3.8+.
When an MCP server is unreachable, the MCP library's internal anyio task
group raises CancelledError, which escaped all three 'except Exception'
handlers in _connect_on_owner(). This propagated through
_run_lifecycle_owner -> _run_on_lifecycle_owner -> connect -> __aenter__,
bypassing user except Exception blocks entirely.

Fix: change the three except-Exception clauses in _connect_on_owner to
'except (Exception, asyncio.CancelledError)' so spurious CancelledErrors
from the MCP transport layer are caught and wrapped in ToolException,
consistent with the method's documented contract.

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

* fix(mcp): propagate genuine task CancelledError in connect() (#5667)

On Python >= 3.11, check task.cancelling() > 0 before wrapping
CancelledError as ToolException in the three except blocks inside
_connect_on_owner(). When the current task is being cancelled by its
caller, the CancelledError now propagates after cleanup, consistent
with the existing pattern at _mcp.py:560-564 and _runner.py:115-120.

On Python < 3.11 task.cancelling() is unavailable, so MCP-internal
CancelledErrors still cannot be reliably distinguished from
caller-driven cancellation; they continue to be wrapped as
ToolException with a comment documenting the trade-off.

Tests:
- Add cleanup assertion to transport-creation CancelledError test
- Add MCPStdioTool variants exercising the 'command' message branches
  for both transport-creation and initialize CancelledError paths
- Add Python 3.11+-gated tests verifying genuine task cancellation
  propagates (and still cleans up) for transport and initialize stages

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

* fix(mcp): log CancelledError with exc_info before wrapping in ToolException (#5667)

CancelledError inherits from BaseException (not Exception) on Python >= 3.8,
so the 'inner_exception=ex if isinstance(ex, Exception) else None' guard
always yields None for CancelledError. This means ToolException.__init__
calls logger.log(level, message, exc_info=None), dropping the traceback.

Add an explicit logger.debug(error_msg, exc_info=ex) before each
raise ToolException(...) in the three CancelledError handlers so the
full traceback is preserved in debug logs when MCP-internal cancellation
is wrapped rather than propagated.

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

* Address review feedback for #5667: Python: [Bug]: Error Handling Issue regarding Python MCPStreamableHTTPTool Class

* refactor(_mcp): extract cancellation helper, fix session error msg and exc_info

- Extract _should_propagate_cancelled_error() helper to eliminate duplicated
  genuine-cancellation detection logic across the three connect() except blocks
- Fix session-creation ToolException message to include exception details
  (e.g. 'Failed to create MCP session: <ex>') matching the transport and
  initialize failure paths
- Change exc_info=ex to exc_info=True in all three logger.debug() calls
  for idiomatic logging
- Add tests for _should_propagate_cancelled_error helper
- Add regression test asserting session error message includes exception text
- Add test verifying logger.debug is called with exc_info=True

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

* refactor: factor out _close_and_check_cancelled helper in _connect_on_owner

Addresses review comment on PR #5687:

1. Add _close_and_check_cancelled() helper method that combines
   _safe_close_exit_stack() + _should_propagate_cancelled_error() into a
   single await-able call. This eliminates the duplicated close-then-check
   pattern that appeared identically in all three connect phases (transport,
   session, initialize), reducing future drift risk.

2. Comments 2 and 3 (missing {ex} in session error message and non-idiomatic
   exc_info=ex) were already addressed in the current code: all error messages
   include {ex} and all logger.debug calls use exc_info=True.

3. Add test_connect_genuine_cancellation_during_session_creation_propagates
   to cover the previously untested genuine-cancellation path in the
   session-creation phase (transport and initialize phases already had tests).

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

* Address review feedback for #5667: review comment fixes

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 17:58:30 +00:00
1489d6620e .NET: feat: Update Github Copilot SDK to 1.0.0-beta.2 (#5699)
* feat: Update Github Copilot SDK to 1.0.0-beta.2

* Fix formatting

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

* fix: Update for breaking changes in Github.Copilot.SDK

* fix sample project

* fix: whitespace formatting

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 19:15:10 +01:00
8bb4692678 Python: Add base_url parameter to AnthropicClient and RawAnthropicClient (#5685)
* feat(anthropic): add base_url parameter to AnthropicClient and RawAnthropicClient

Add base_url support to AnthropicSettings TypedDict, RawAnthropicClient,
and AnthropicClient so users can point the client at Foundry or other
Anthropic-compatible endpoints without having to construct AsyncAnthropic
manually.

- Add base_url field to AnthropicSettings (resolved from ANTHROPIC_BASE_URL env var)
- Add base_url parameter to RawAnthropicClient.__init__ and pass it to AsyncAnthropic
- Add base_url parameter to AnthropicClient.__init__ and forward to super
- Add unit tests for base_url on both client classes

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

* Python: Add `base_url` parameter to `AnthropicClient` and `RawAnthropicClient`

Fixes #5683

* test: add ANTHROPIC_BASE_URL env fallback tests for issue #5683

Add unit tests verifying that both AnthropicClient and RawAnthropicClient
pick up base_url from the ANTHROPIC_BASE_URL environment variable via
load_settings when base_url is not passed explicitly as a constructor arg.

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

* test(anthropic): explicit base_url kwarg beats ANTHROPIC_BASE_URL env var (#5683)

Add regression tests asserting that when both ANTHROPIC_BASE_URL is set
in the environment *and* an explicit base_url kwarg is passed to
AnthropicClient / RawAnthropicClient, the explicit kwarg wins.

This closes the priority-ordering contract (explicit arg > env var) that
the existing tests left implicit.

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-07 17:57:09 +00: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
e7dc3b91f1 .NET: Add Microsoft.Agents.AI.Hyperlight package for CodeAct integration (.NET) (#5329)
* Add Microsoft.Agents.AI.Hyperlight package for CodeAct integration

Introduces a new Microsoft.Agents.AI.Hyperlight package that enables CodeAct-style sandboxed code execution via Hyperlight (hyperlight-sandbox .NET SDK, PR #46) for .NET agents, following the docs/features/code_act/dotnet-implementation.md design and the Python agent_framework_hyperlight reference.

Highlights:
- HyperlightCodeActProvider (AIContextProvider): injects an execute_code tool and CodeAct guidance per invocation; single-instance-per-agent via a fixed StateKeys value; supports multiple provider-owned tools (exposed inside the sandbox via call_tool), file mounts, and an outbound domain allow-list; snapshot/restore per run.
- HyperlightExecuteCodeFunction: standalone AIFunction for manual/static wiring when the sandbox configuration is fixed.
- Approval model via CodeActApprovalMode (AlwaysRequire / NeverRequire) with propagation from ApprovalRequiredAIFunction-wrapped tools.
- Unit tests (instruction builder, tool bridge, approval computation, provider CRUD, ProvideAIContextAsync snapshot isolation and approval wrapping).
- Env-gated integration test (HYPERLIGHT_PYTHON_GUEST_PATH).
- Three samples under samples/02-agents/AgentWithCodeAct (interpreter, tool-enabled, manual wiring).

Build is not yet runnable: requires .NET SDK 10.0.200 and the not-yet-published HyperlightSandbox.Api 0.1.0-preview NuGet package. Package is marked IsPackable=false until the dependency is available.

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

* Address PR #5329 review feedback for Hyperlight CodeAct provider

- A. Build-breakers: drop unused usings, override test TargetFrameworks
  off net472, drop redundant Microsoft.Extensions.AI.Abstractions PackageRef.
- B. API: keep CRUD but rebuild sandbox when config fingerprint changes;
  add HyperlightCodeActProviderOptions.CreateForWasm/CreateForJavaScript
  factory methods (Backend/ModulePath now read-only); rename WorkspaceRoot
  to HostInputDirectory; convert AllowedDomain & FileMount from record to
  sealed class; drop ToolBridge.Unwrap (ApprovalRequiredAIFunction is
  invocable as-is).
- C. ToolBridge: collapse SerializeResult switch; add comment explaining
  AOT-driven choice to keep JsonNode.Parse over typed Deserialize.
- D. InstructionBuilder: drop language-specific 'Python code' phrasing;
  strip host filesystem paths from execute_code description.
- E. Style polish: ternary expression-body for ComputeApprovalRequired,
  .Where(x is not null), .ToList() over .ToArray() in IReadOnlyList
  returns.
- F. Samples: add guest-module / KVM-WHP build instructions to Step01;
  note future Excel-upload sample in Step02.

Also adds SandboxExecutorTests covering the new RunSnapshot.ComputeFingerprint
used for sandbox-rebuild detection.

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

* Align Hyperlight package id and JS warm-up with merged upstream SDK

The .NET SDK in hyperlight-dev/hyperlight-sandbox PR #46 has merged. The
published package id is Hyperlight.HyperlightSandbox.Api (the bare
HyperlightSandbox.Api remains the assembly/namespace) and the reference
CodeExecutionTool uses 'void 0;' as the JavaScript warm-up no-op. Update
the package reference, project comment, README, and SandboxExecutor warm-up
accordingly.

No functional change beyond that — all other public APIs we depend on
(SandboxBuilder.With*, Sandbox.Run/RegisterToolAsync/AllowDomain/Snapshot/
Restore, ExecutionResult, SandboxBackend) match the merged shape.

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

* Bump Hyperlight package to 0.4.0 and fix build/test issues

Hyperlight.HyperlightSandbox.Api 0.4.0 is now published on nuget.org. Bump
the version reference and address the analyzer/runtime issues that surfaced
once restore could complete:

- Add HyperlightJsonContext source-generated JsonSerializerContext for the
  execute_code result + tool error envelopes; route arbitrary AIFunction
  results through AIJsonUtilities.DefaultOptions to keep IsAotCompatible=true.
- Replace explicit ObjectDisposedException throws with
  ObjectDisposedException.ThrowIf (CA1513).
- Use HyperlightSandbox.Api.SandboxBackend in cref docs to disambiguate.
- Update tests to match AIContext.Tools being IEnumerable<AITool>, drop
  ConfigureAwait(false) in xUnit test methods (xUnit1030), use collection
  expressions for AllowedDomain methods.
- Add 'using OpenAI.Chat;' to all three samples so AsAIAgent resolves.
- Verified: dotnet build of all four hyperlight projects + samples succeeds
  on net8/9/10; dotnet test for the unit tests passes 32/32 on net10.0.

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

* Fix CI check failures: file encoding (UTF-8 BOM + LF) and broken markdown link

- Convert all new .cs/.csproj files to UTF-8 with BOM and LF line endings
  to satisfy the dotnet/.editorconfig charset/end_of_line settings
  enforced by check-format.
- Drop unused System.Collections.Generic using in HyperlightCodeActProviderTests.
- Add missing using Microsoft.Extensions.AI in CodeActApprovalMode.cs and
  shorten ApprovalRequiredAIFunction cref (IDE0001).
- Fix broken README link to docs/decisions/0024-codeact-integration.md.

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

* Address PR review: AIFunction inheritance, packaging, GetService approval check

- HyperlightExecuteCodeFunction now inherits AIFunction directly. The
  AsAIFunction() indirection is gone; instances are accepted anywhere an
  AIFunction is. Approval requirement is surfaced via GetService<ApprovalRequiredAIFunction>()
  which lazily exposes a wrapping ApprovalRequiredAIFunction proxy when the
  effective ApprovalMode/tool stack requires it.
- ComputeApprovalRequired now uses GetService<ApprovalRequiredAIFunction>() so
  approval-required tools nested anywhere in the AITool decorator stack are
  detected (not just the top-most class).
- csproj: drop IsPackable=false (ready to release with the published
  Hyperlight.HyperlightSandbox.Api 0.4.0 dependency); add PackageReadmeFile
  and pack README.md at the package root, matching the pattern used by
  Aspire.Hosting.AgentFramework.DevUI / Microsoft.Agents.AI.DurableTask.
- Update Step03 sample and README wording to reflect direct AIFunction usage.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 12:56:24 +00:00
d7ca9c8f16 Python: Core: add experimental session-mode harness context provider (#5611)
* Python: Core: add experimental session-mode harness context provider

Introduces the _harness namespace and the first context provider:
SessionModeContextProvider, with get_session_mode / set_session_mode
helpers and a DEFAULT_MODE_SOURCE_ID constant. Behind
@experimental(ExperimentalFeature.HARNESS).

Also folds in a small _sessions.py cleanup (try/except ImportError
-> contextlib.suppress) touched while developing the harness.

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

* Python: Core: align session-mode harness with .NET AgentModeProvider

Mirror the default mode descriptions and instruction template used
by the .NET AgentModeProvider so the cross-language harness UX is
consistent.

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

* Python: Core: address review feedback on session-mode harness

- json.dumps tool outputs to stay valid for arbitrary mode names
- normalize configured mode keys (lower+strip) so custom-cased configs work
- raise TypeError instead of silently replacing non-dict session state
- mark get_session_mode/set_session_mode as @experimental(HARNESS)

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

* Python: Core: rename SessionModeContextProvider to AgentModeProvider

Match the .NET AgentModeProvider class name for cross-language
consistency. Helpers renamed accordingly: get_session_mode ->
get_agent_mode, set_session_mode -> set_agent_mode. The default
source_id is now "agent_mode". Construction pattern stays Pythonic
(kwargs, not an options object).

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

* Python: Core: address AgentModeProvider review feedback

- default_mode now defaults to None and falls back to the first configured
  mode, decoupling the kwarg from the built-in 'plan'/'execute' set.
- get_agent_mode catches ValueError when a previously persisted mode is no
  longer in available_modes and resets to the default mode (matching the
  non-string recovery branch). Added regression coverage for both behaviors.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 10:09:19 +00:00
57c901a245 Python: Fix hyperlight WasmSandbox cross-thread Drop and harden hosted-agent sample (#5603)
* update hyperlight to beta and move samples, add hosted agent sample

* Python: Fix hyperlight WasmSandbox cross-thread Drop and harden sample

Root cause: when a worker-side closure raised, the exception's __traceback__
retained frame locals that included the partially constructed PyO3 sandbox.
Future.result() re-raised that exception on the caller thread, and when the
caller's exception was eventually GC'd the frame locals were released
off-thread, dec_ref'ing the unsendable sandbox from the wrong thread and
tripping the PyO3 panic
'_native_wasm::WasmSandbox is unsendable, but is being dropped on another thread'.

Fix:
* Add _SandboxWorker._run_on_worker which catches every exception on the
  worker, drops __traceback__ there, deletes the original exception, and
  re-raises a fresh instance on the caller thread. initialize and execute
  route through it; dispose keeps its bare-submit semantics.
* Add an opt-in diagnostic module _drop_diagnostic (no-op unless
  HYPERLIGHT_TRACE_DROPS=1) that installs a sys.unraisablehook and dumps
  owner-thread + per-thread stacks on any future cross-thread unsendable
  Drop. Useful for triaging similar PyO3 regressions.
* Tests: cross-thread invocation, traceback-leak isolation, _SandboxEntry
  attribute-shape check, and a stale-reference stress test driven through
  asyncio.to_thread.

Sample (samples/04-hosting/foundry-hosted-agents/responses/06_hyperlight_codeact):
* Dockerfile installs agent-framework-* from in-tree source with python/ as
  build context so unreleased fixes can be validated end-to-end.
* call_server.py pins the Responses API version.
* main.py enables include_detailed_errors=True so future tool failures
  surface the actual exception text instead of a bare 'Error: Function
  failed.' string.
* README.md documents the in-tree-package build and the Hyperlight
  hypervisor requirement (/dev/kvm on Linux, MSHV on Windows). Hosted
  environments without hypervisor passthrough surface 'No Hypervisor was
  found for Sandbox'; this is a hosting constraint, not a hyperlight bug.

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

* Python: remove _drop_diagnostic from hyperlight package

The diagnostic module was useful while bisecting the cross-thread Drop bug,
but it is no longer needed now that _SandboxWorker._run_on_worker prevents
the panic at the source.

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

* Python: address PR review feedback on hyperlight

- Use lazy agent_framework.hyperlight import in sample main.py.
- Env-driven endpoint (FOUNDRY_AGENT_ENDPOINT) in call_server.py; remove personal URLs.
- Align agent.yaml model deployment with manifest (gpt-4.1-mini).
- Tighten Dockerfile requirements guard; drop dangling deploy.ps1 reference.
- Preserve exception args when sanitizing tracebacks in _run_on_worker.
- Add public _SandboxWorker.is_alive(); update test to avoid private attr.
- Add namespace coverage tests for agent_framework.hyperlight lazy loader.
- Add prominent note: Foundry hosted-agent runtime does not yet support
  Hyperlight (no hypervisor exposed); container works locally with /dev/kvm.

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

* Python: bump hyperlight-sandbox dependencies to 0.4.x

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

* Python: renumber hyperlight codeact sample to 08

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

* Coerce worker exception args to strings for cross-thread safety

Stringify exc.args on the worker thread before propagating, so any
PyO3 unsendable object captured in args (e.g. via a caller-supplied
callback or underlying SDK) cannot be Dropped on the calling thread.

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

* moved sample

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 10:06:16 +00:00
westeyandGitHub 36b9b41e3b Update version for release (#5636) 2026-05-05 09:23:36 +00:00
550209fe6e Python: Core: add experimental todo-list harness context provider (#5612)
* Python: Core: add experimental todo-list harness context provider

Adds TodoListContextProvider with pluggable TodoStore backends:
TodoSessionStore (in-session) and TodoFileStore (JSONL on disk).
Public types: TodoItem, TodoInput. Behind
@experimental(ExperimentalFeature.HARNESS).

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

* Python: Core: align todo harness instructions with .NET TodoProvider

Reformat DEFAULT_TODO_INSTRUCTIONS to mirror the .NET TodoProvider
DefaultInstructions wording and structure, and bring the class
docstring closer to the .NET XML <remarks> block. Keeps Python tool
names in snake_case.

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

* Python: Core: address review feedback on todo harness

- mark TodoStore as @experimental(HARNESS) for surface consistency
- TodoSessionStore.load_state now raises ValueError on malformed items
- TodoFileStore now namespaces persisted state by source_id
- TodoFileStore now safely encodes session_id/owner and verifies path containment (matches FileHistoryProvider pattern)
- per-(session, source_id) asyncio.Lock around read-modify-write to avoid races

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

* Python: Core: rename TodoListContextProvider to TodoProvider

Match the .NET TodoProvider class name for cross-language consistency.
Other public types (TodoStore, TodoSessionStore, TodoFileStore,
TodoItem, TodoInput) are unchanged. Construction stays Pythonic
(kwargs, not an options object).

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

* Python: Core: address TodoProvider review feedback

- TodoStore.load_state/save_state are now async; TodoFileStore performs
  disk I/O via asyncio.to_thread so the event loop is no longer blocked
  while the per-session mutation lock is held.
- TodoSessionStore now raises ValueError for malformed top-level state
  (non-dict / non-list 'items' / non-int 'next_id') to match the
  TodoFileStore contract instead of silently re-defaulting.
- Both stores now clamp next_id to max(item.id) + 1 after load to make
  ID collisions impossible after recovery or reconfiguration.
- TodoFileStore writes atomically by writing a sibling temp file and
  os.replace-ing it so a crash mid-write cannot truncate the state file.
- TodoFileStore.load_state no longer creates parent directories for
  sessions that never write; mkdir is deferred to save_state.
- TodoProvider mutation locks now live in a weakref.WeakKeyDictionary
  keyed by AgentSession, so locks for GC'd sessions are evicted instead
  of leaking in long-running services.

Tests cover each change including a TodoFileStore-backed end-to-end
provider flow, atomic-write recovery, and lock GC eviction.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 08:39:41 +00:00
27f926609f Python: Fix incorrect workflow timings in DevUI by adding created_at to executor events (#5615)
* fix(devui): add created_at to custom output item events for correct workflow timings (#5545)

CustomResponseOutputItemAddedEvent and CustomResponseOutputItemDoneEvent lacked a
created_at field, causing the frontend to synthesize timestamps using integer-second
precision with a forced +1s minimum gap between events. This made instant workflows
appear to take 3+ seconds in the DevUI timeline.

Fix:
- Add optional created_at: float | None field to both custom event models
- Populate created_at=float(time.time()) in the mapper for executor_invoked,
  executor_completed, and executor_failed events

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

* fix(devui): use event created_at for accurate workflow timeline timings

workflow-view.tsx synthesized _uiTimestamp using Math.max(baseTimestamp,
lastTimestamp + 1) with integer-second precision, forcing a minimum 1-second
gap between every sequential event. This made instant workflows appear to take
several seconds in the DevUI timeline.

The fix prefers event.created_at (a float Unix timestamp populated by the
backend mapper for all executor events) and only falls back to the synthetic
timestamp when created_at is absent. This matches the pattern already used in
devuiStore.ts:addDebugEvent.

Added a regression test in test_mapper.py verifying that the mapper attaches
created_at to all executor lifecycle events (invoked, completed, failed).

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

* fix(devui): address review feedback for issue #5545

- Read data.timestamp (ISO string) and response.created_at in addition
  to top-level created_at when deriving _uiTimestamp, so
  response.workflow_event.completed events get a real server timestamp
  instead of a synthesized one
- Change uniqueTimestamp tiebreaker: when a real server timestamp is
  available use Math.max(eventTimestamp, lastTimestamp) rather than
  lastTimestamp + 1, eliminating artificial 1-second gaps while still
  preserving monotonic ordering
- Apply the same fix in the HIL streaming path (second setOpenAIEvents
  call in workflow-view.tsx)
- Add assert event.created_at > 0 to regression test to guard against
  zero or negative timestamps
- Add test_custom_output_item_event_models_have_created_at_field model-
  level test so removing the field produces a clear named failure rather
  than a downstream ValidationError

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

* fix(#5545): guard NaN timestamps, fix fallback ID uniqueness, add regression tests

- workflow-view.tsx (×2): Wrap data.timestamp ISO→number conversion in a
  Number.isFinite() guard.  Python's datetime.now().isoformat() emits
  microseconds without a trailing 'Z' (e.g. '2024-01-15T12:34:56.123456'),
  which some JS engines cannot parse, returning NaN.  NaN !== undefined is
  true so the eventTimestamp !== undefined guard did not catch it, poisoning
  _uiTimestamp and resetting the monotonic ordering seed (NaN || 0 → 0).

- execution-timeline.tsx: Replace uiTimestamp in the fallback syntheticItemId
  with the per-executor runNumber counter.  Two runs of the same executor
  within the same second previously received identical _uiTimestamp values
  and therefore identical syntheticItemIds, causing their output buckets,
  state, and run entries to collide (execution-timeline.tsx:360–408).

- Add missing test_workflow_timings_bug.py source file (only a stale .pyc
  existed).  Three regression tests:
    · test_custom_event_models_lack_created_at_field – model field guard
    · test_workflow_executor_events_lack_created_at – mapper populates created_at
    · test_rapid_workflow_events_have_no_top_level_timestamps – confirms
      data.timestamp format that requires the frontend NaN guard

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

* Address review feedback for #5545: Python: [Bug]: Workflow timings in DevUI are incorrect

* devui: move timing regression tests into test_mapper.py, remove dedicated bug file

- Delete test_workflow_timings_bug.py; tests belong in existing module files
- The two tests already present in test_mapper.py (test_executor_events_carry_created_at_timestamp
  and test_custom_output_item_event_models_have_created_at_field) cover the same ground as the
  first two tests in the deleted file
- Add test_executor_completed_maps_to_output_item_done_event to test_mapper.py, replacing the
  third test from the deleted file with a generic, issue-agnostic name and docstring

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

* Address review feedback for #5545: review comment fixes

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 05:59:08 +00:00
chetantoshniwalGitHubCopilotchetantoshniwalEvan Mattsoncopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
7476049d7e docs: enhance README with 1.0 features and improved structure (#5534)
* docs: enhance README with 1.0 features and improved structure

- Add GitHub star badge button for easier community engagement
- Reorganize highlights to emphasize Foundry Hosted Agents, Agent Skills, and Orchestration Patterns
- Add CodeAct callout in AF Labs experimental features
- Improve Community & Feedback section with clearer call-to-action structure
- Add Table of Contents for better navigation
- Fix 'quickstar' typo to 'quickstart'
- Reorder sections for improved readability (docs before code examples)

* Apply suggestions from code review

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

* docs: fix .NET quickstart description to match JokerAgent code

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/a0616215-1b8a-44ea-9a35-3ef33b97bdce

Co-authored-by: chetantoshniwal <255221507+chetantoshniwal@users.noreply.github.com>

* docs: add required NuGet packages for .NET Foundry quickstart

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/a035ce2c-e2e0-4b8d-b340-550704220975

Co-authored-by: chetantoshniwal <255221507+chetantoshniwal@users.noreply.github.com>

* docs: sync and apply local README changes

* Update README.md

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>

* docs: remove emojis from README

* docs: refine README intro paragraph

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: chetantoshniwal <255221507+chetantoshniwal@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-05-05 01:19:57 +00:00
Tao ChenandGitHub 5a087885a2 Python: Add hosted agent sample with observability (#5608)
* Add hosted agent sample with observability

* Address comments

* Remove unneeded changes

* Update README
2026-05-04 22:31:47 +00:00
4b5a8478de .NET: Hosting updates to declarative workflows (#5589)
* Make DeclarativeWorkflowExecutor ChatProtocol-compatible for AsAIAgent hosting

Extends the existing DeclarativeWorkflowExecutor<TInput> root executor with
additional ChatProtocol-compatible input routes (string, ChatMessage,
IEnumerable<ChatMessage>, ChatMessage[], TurnToken) so that workflows built
via DeclarativeWorkflowBuilder.Build<TInput>(...) work both for direct
invocation and when hosted via Workflow.AsAIAgent(...).

- Each input message advances the declarative graph immediately; the
  TurnToken that the host sends after the message batch is treated as a
  no-op since the message has already been processed.
- Conversation id resolution now prefers persisted workflow system state,
  then DeclarativeWorkflowOptions.ConversationId, then a newly created
  conversation. This makes multi-turn invocations reuse the prior
  conversation rather than creating a fresh one each turn.
- The separate DeclarativeChatProtocolStartExecutor and
  DeclarativeWorkflowBuilder.BuildChatProtocol overloads introduced
  earlier are removed; callers continue to use Build<TInput>(...).

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

* fix: use DeclarativeWorkflowContext when reading workflow conversation id

GetWorkflowConversation() requires a DeclarativeWorkflowContext (it calls ReadState which dynamic-casts via the DeclarativeContext helper). The chat-protocol auxiliary handlers receive a BoundWorkflowContext, so calling the extension on the raw IWorkflowContext throws `Invalid workflow context: BoundWorkflowContext`. Use the wrapped declarativeContext that we already constructed.

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

* fix: surface ExecutorFailedEvent as ErrorContent in AsAIAgent response

WorkflowSession.InvokeStageAsync only converted WorkflowErrorEvent into an ErrorContent payload. ExecutorFailedEvent fell through to the default branch which emits an empty AgentResponseUpdate carrying the event in RawRepresentation. OutputConverter then mapped that to a workflow_action item with status=failed and dropped the exception entirely, so callers got status=completed and error=null even when an executor threw.

- WorkflowSession.cs: add ExecutorFailedEvent case mirroring WorkflowErrorEvent. Honors _includeExceptionDetails.

- OutputConverter.cs: when an update carries both a WorkflowEvent in RawRepresentation and non-empty Contents, fall through to content processing so the unwrapped error (or any future content payload from a workflow event) is actually emitted.

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

* improve: walk inner exceptions when surfacing ExecutorFailedEvent

DeclarativeActionExecutor wraps inner exceptions in DeclarativeActionException with a generic `Unhandled workflow failure` message, hiding the real cause. Walk InnerException so the response shows the full chain (e.g. the underlying HTTP 400 / auth error).

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

* Surface declarative SendActivity output as chat content

SendActivityExecutor now emits AgentResponseEvent in addition to
MessageActivityEvent so chat protocols (e.g. AsAIAgent) receive the
formatted activity text. The existing MessageActivityEvent is preserved
for DevUI/observability.

Also extend WorkflowSession.WorkflowOutputEvent handling to accept
AgentResponse payloads, mapping them to their constituent ChatMessages.

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

* Persist hosted-agent sessions to disk; fix System.LastMessageText

Adds FileSystemAgentSessionStore that writes the serialized AgentSession JSON

(which already embeds the workflow's in-memory checkpoint manager) to a per-

conversation file under /.checkpoints when running in a Foundry hosted env

or {cwd}/.checkpoints locally. Mirrors the python foundry_hosting._responses

FileCheckpointStorage pattern so multi-turn workflow state survives process

restarts without requiring callers to wire up storage themselves.

AddFoundryResponses now defaults to FileSystemAgentSessionStore.CreateDefault()

instead of InMemoryAgentSessionStore; callers can still override via DI.

Also fixes {System.LastMessageText} resolving empty: DeclarativeWorkflowExecutor

.AdvanceAsync was passing the message rehydrated from CreateMessageAsync to

SetLastMessageAsync, but ResponseItem -> ChatMessage round-trip drops the .Text

extension content. Use the original input ChatMessage (which still has the

user-supplied text) and copy the server-assigned MessageId across when present.

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

* Close multi-modal input parity gaps with python foundry_hosting

InputConverter now mirrors the python _responses.py content handling:

- ComputerScreenshotContent maps to UriContent/HostedFileContent (was dropped).

- Plain TextContent and SummaryTextContent map to MEAI TextContent.

- MessageContentReasoningTextContent maps to MEAI TextReasoningContent.

- input_file with text/* file_data data URIs is decoded inline into

  TextContent with a [File: name] prefix, matching python _convert_file_data

  so {System.LastMessageText} surfaces the file body. Non-text data URIs and

  hosted/url file references preserve filename as AdditionalProperties.

Image/file extraction logic is extracted into shared AppendImageContent and

AppendFileContent helpers used by both the fresh-input and history-replay

switches. Existing 37 InputConverter tests still pass.

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

* Foundry hosting: round-trip tool-approval (HITL) content as mcp_approval_request/response

Closes the gap where Microsoft.Agents.AI.Foundry.Hosting silently dropped
MEAI ToolApprovalRequestContent/ToolApprovalResponseContent in both
directions. We now serialize them onto the wire as the standard Responses
API mcp_approval_request/mcp_approval_response items with
server_label='agent_framework', and parse the symmetric inbound shapes
back into MEAI content.

Wire format:
- The Responses API only standardizes mcp_approval_* as the approval
  primitive. We declare AF as a virtual MCP server via the server_label
  field, which is honest for AF's server-side tool-call holding pattern.
- The SDK enforces a strict {prefix}_{50hex} wire-id format, so we hash
  the AF RequestId and persist a wireId<->afRequestId mapping in
  AgentSession.StateBag so a later mcp_approval_response can be matched
  back to the originating workflow request.

Coexists with the existing ConsentAwareMcpClientAIFunction flow
(AgentFrameworkResponseHandler.cs) which emits mcp_approval_request from
a side-channel, not via OutputConverter's content switch.

Known follow-up: python (foundry_hosting/_responses.py) has the same
output-side gap (ToolApprovalRequestContent emission). Out of scope here.

Tests: +9 unit tests covering both fresh-input and history-replay shapes,
StateBag mapping resolution, and the non-FunctionCallContent skip path.
Existing 108 converter tests still pass; full suite 370/370.

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

* Address PR review feedback for hosted-declarative-dotnet

FileSystemAgentSessionStore reliability/scoping:

- Bound Sanitize() stackalloc at 256 chars, fall back to ArrayPool for longer ids so a long conversationId can no longer crash the hosting process with StackOverflowException.

- Use a Guid-suffixed temp file (\{path}.{guid}.tmp\) so concurrent SaveSessionAsync calls on the same conversation can no longer race on the same temp file. Best-effort temp cleanup on failure.

- Bucket session files by agent.Name when set so two keyed agents that happen to share a conversationId no longer overwrite each other's persisted state. Single-agent / unnamed-agent cases keep the original flat layout (Python parity).

DeclarativeWorkflowExecutor chat-protocol routing:

- ConfigureChatProtocolRoutes uses IsAssignableFrom rather than exact type equality so a broader TInput (object, base interfaces) does not have its inherited inputTransform shadowed by handlers we register here.

- HandleChatMessagesAsync / HandleChatMessageArrayAsync now advance through every message in the batch instead of keeping only the trailing one, so multi-message turns and replayed history are no longer silently truncated. AdvanceAsync gains a finalizeTurn flag so only the last message in the batch sends the result.

Tests:

- New FileSystemAgentSessionStoreTests covering constructor, fresh-session fallback for missing/empty files, root-directory creation, save/get round-trip, agent-Name scoping isolation, long conversationId, invalid-character sanitization, and concurrent-save behavior.

- New InputConverterTests covering AppendFileContent: text/* data URI decode (with and without filename prefix), non-text data URI passthrough, malformed data URI fallback, and filename propagation onto UriContent / HostedFileContent.

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

* Add tests for remaining PR review feedback (C2, D1, E1)

C2: InputConverter — add 9 tests covering SDK content types that previously
had no coverage:
  - SdkTextContent → TextContent (input + output paths)
  - SummaryTextContent → TextContent (input + output paths)
  - MessageContentReasoningTextContent → TextReasoningContent (input + output)
  - ComputerScreenshotContent (HTTP URL → UriContent, data: URI → DataContent,
    output path → UriContent)

D1: OutputConverter — add 2 tests for the WorkflowEvent + Contents fall-through:
  - WorkflowEvent in RawRepresentation with text Contents must flow through
    the content-processing path (text-delta event emitted).
  - WorkflowEvent + ErrorContent must produce a failed event rather than be
    swallowed by the workflow branch.

E1: SendActivityExecutor — extend CaptureActivityAsync to assert that the
executor emits an AgentResponseEvent carrying the activity text with the
correct ExecutorId and ChatRole.Assistant role.

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

* Defense-in-depth: neutralize dot-segments in Sanitize and cap TryDecodeTextDataUri input size

Addresses claude-opus-4.6 security review on PR #5589:

- FileSystemAgentSessionStore.Sanitize now replaces all-dot segments
  (., .., ...) with underscores so a developer-controlled agent.Name
  cannot escape the root directory on Linux (where Path.GetInvalidFileNameChars
  only contains NUL and '/').

- InputConverter.TryDecodeTextDataUri rejects encoded payloads larger than
  16 MiB before calling Convert.FromBase64String, preventing a single
  oversized data URI from triggering a multi-megabyte allocation.

- Adds unit tests covering both fixes.

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

* Fix Linux-only failure in SaveSessionAsync_SanitizesInvalidPathCharactersAsync

'?' is in Path.GetInvalidFileNameChars only on Windows, not on Linux/macOS,
so the test failed on Ubuntu in CI. Use Path.GetInvalidFileNameChars()[0]
(skipping NUL) to pick a guaranteed-invalid character for the running OS,
and assert the result no longer contains it.

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

* Address claude-opus-4.6 security/reliability review feedback

WorkflowSession.cs:
- ExecutorFailedEvent handler no longer leaks the internal executor ID
  in error messages. Mirror the WorkflowErrorEvent pattern: surface the
  exception's Message when _includeExceptionDetails is true, fall back
  to the generic 'An error occurred while executing the workflow.' otherwise.
  This also resolves the failing WorkflowHostSmokeTests assertions.

FileSystemAgentSessionStore.cs:
- GetSessionPath no longer has a write side effect. Directory.CreateDirectory
  for the per-agent bucket is now performed only on the SaveSessionAsync
  path, so a read miss on GetSessionAsync no longer leaves an empty
  directory on disk.
- Adds GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync
  to lock in the no-side-effect-on-read contract.

OutputConverterTests.cs:
- Strengthen ConvertUpdatesToEventsAsync_ToolApprovalRequest_NonFunctionToolCall_SkippedAsync
  to assert exactly one event (the terminal ResponseCompletedEvent) so a
  spurious output-item-added/-done leak would now fail the test.

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

* Address PR review: clean up comments and rename TryParseArguments

- Remove Python-codebase references from C# XML docs and inline comments.
- Drop fix-history comments referring to previously-resolved issues.
- Drop `Defense-in-depth:` prefixes; keep the concrete `what & why`.
- Drop `previously we kept only the trailing message` comment in
  DeclarativeWorkflowExecutor; just describe current loop behavior.
- Rename InputConverter.TryParseArguments to ParseFunctionArgumentsObject
  to make the intent obvious at the call site.

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

* Address PR review: collision-free Sanitize, MAF-style refactors

- FileSystemAgentSessionStore.Sanitize now percent-encodes invalid chars
  (and `%` itself) instead of replacing them with `_`, eliminating
  collisions like `foo/bar` vs `foo_bar` mapping to the same bucket.
  All-dot segments encode every dot so Windows trailing-dot trimming
  cannot reintroduce a navigable name.
- AddFoundryResponses XML doc updated to accurately describe the default
  store root (/.checkpoints when hosted, {cwd}/.checkpoints locally).
- DeclarativeWorkflowExecutor.ConfigureChatProtocolRoutes now uses exact
  type equality instead of IsAssignableFrom so a broad TInput (e.g.
  object) does not skip registering IEnumerable<ChatMessage>, which
  ChatProtocolExtensions.IsChatProtocol requires verbatim.
- SendActivityExecutor uses context.YieldOutputAsync(response) instead
  of manually constructing AgentResponseEvent, so the activity will
  participate in any future OutputFilter coverage.
- WorkflowSession handles AgentResponseEvent in its own switch case,
  avoiding the second typecheck against output.Data.

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

* fix(workflows): bridge declarative HITL through Foundry hosting via IExternalRequestEnvelope

Introduce a new public interface IExternalRequestEnvelope in
Microsoft.Agents.AI.Workflows that lets the runtime peek through a
declarative-layer envelope without taking a circular reference back into
the declarative package. ExternalInputRequest (declarative) implements
it; ExternalInputResponse is constructed via the request's CreateResponse
factory. WorkflowSession unwraps inner AIContent on the request side and
rewraps the client's ChatMessage reply into an ExternalInputResponse on
the response side. PortableValue cannot deserialize directly into an
interface, so TryGetRequestEnvelope resolves the concrete type via
RequestPortInfo.RequestType (TypeId -> Type.GetType) before casting.

Public WorkflowHarness contract preserved: InvokeFunctionToolExecutor
and WorkflowActionVisitor are unchanged from upstream, so public
InvokeToolWorkflowTest scenarios continue to drive
ExternalInputRequest / ExternalInputResponse directly through the
harness.

AgentFrameworkResponseHandler: skip prior conversation history replay
when an existing session is being resumed (workflow checkpoint already
holds the prior messages).

WorkflowSession: when includeExceptionDetails is opted in, also unwrap
DeclarativeActionException so HITL failures are debuggable.

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-04 22:09:54 +00:00
330d3d7165 fix(openai): drop completed continuation_token from shared options in tool loop (#5462)
Fixes #5394.

When `background=True` is combined with local function tools,
`FunctionInvocationLayer` calls `_inner_get_response(options=mutable_options)`
repeatedly with the same dict reference across loop iterations. Once the
first poll retrieves a completed background response, `continuation_token`
stays in `mutable_options`, so every subsequent iteration takes the
`continuation_token is not None` branch and `GET`s the same completed
response instead of `POST`ing the tool results. The loop exits after
`max_iterations` with empty text and the model never sees any tool output.

After the retrieve, if the returned `ChatResponse.continuation_token` is
`None` (the background response is no longer in progress), pop
`continuation_token` and `background` from the shared options dict in
place. The next loop iteration then falls through to the normal
`responses.create`/`parse` path and posts tool results.

The diagnosis and a verified runtime monkeypatch are in the issue; this
is the same fix moved in-tree.

Co-authored-by: Yufeng He <40085740+universeplayer@users.noreply.github.com>
2026-05-04 21:22:56 +00:00
Evan MattsonandGitHub f3db60fa65 Python: Support GPT-5 verbosity option and restore Foundry agent_reference (#5619)
* Python: Support GPT-5 verbosity option and restore Foundry agent_reference

Adds verbosity as a typed Literal["low","medium","high"] field on
OpenAIChatOptions (Responses API) and OpenAIChatCompletionOptions (Chat
Completions API), set in the same way as the existing reasoning options.
For the Responses API, top-level verbosity is translated to the nested
text.verbosity shape the OpenAI service expects. The same field flows
through to FoundryChatClient via the existing FoundryChatOptions alias.

Also fixes #5582: PR #5447 removed the agent_reference injection from
RawFoundryAgentChatClient._prepare_options, so first-turn calls against
a Foundry Prompt Agent went out without model and without agent_reference
and were rejected by the Responses API with "Missing required parameter:
'model'". Restores the injection on the non-preview path
(allow_preview=False) and adds a guard test that asserts the preview
path does not inject agent_reference, since the preview SDK injects it
via project_client.get_openai_client(agent_name=...).

Closes #5516
Closes #5582

* Python: Address Copilot review on PR #5619

- Foundry verbosity sample docstring: replace the misleading "set deployment
  name on model=" instruction with the actual env-var pattern the sample relies
  on (FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL).
- _build_agent_reference docstring: clarify the helper is used for both
  Prompt Agents and HostedAgents on the non-preview path.
- Add a Responses API test that locks in the documented precedence rule:
  when both top-level verbosity and text["verbosity"] are supplied, the
  top-level value wins.

* Python: Drop redundant Foundry verbosity sample and list OpenAI sample in README

- Remove samples/02-agents/providers/foundry/foundry_chat_client_verbosity.py
  per review feedback. The verbosity functionality is identical across the
  OpenAI and Foundry clients (FoundryChatOptions is an alias of
  OpenAIChatOptions), so a single sample on the OpenAI side is sufficient.
- Add the new client_verbosity.py entry to the OpenAI samples README.
2026-05-04 21:21:40 +00:00
4a2da953ca Python: Core: add experimental memory harness context provider (#5613)
* Python: Core: add experimental memory harness context provider

Adds MemoryContextProvider with topic-indexed long-term memory and
chat-driven compaction. Pluggable MemoryStore backends include
MemoryFileStore. Public types: MemoryIndexEntry, MemoryTopicRecord.
Behind @experimental(ExperimentalFeature.HARNESS).

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

* Python: Core: address review feedback on memory harness

- mark MemoryStore as @experimental(HARNESS) for surface consistency
- safely encode owner id and verify path containment (matches FileHistoryProvider pattern)
- namespace MemoryFileStore on-disk layout by source_id to avoid cross-provider collisions
- before_run computes index_entries once and only rewrites MEMORY.md when content changes
- asyncio locks around topic/state read-modify-write to avoid concurrent-write races

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

* Address PR feedback: harden memory store IO + consolidation behavior

- Atomic writes via os.replace + temp sibling for topic, state, and index files so
  crashes/disk-full failures cannot leave a truncated half-written file.
- Stop creating directories on read paths: list_topics/read_state/search_transcripts
  and get_messages return empty when nothing has been written. mkdir is deferred to
  the actual save path (write_topic/write_state/save_messages).
- Escape lines that look like markdown headings on render and unescape them on parse,
  so a memory or summary containing '## Summary'/'## Memories' cannot tamper with the
  topic file structure.
- Narrow extraction/consolidation chat-client failure handling to ChatClientException,
  asyncio.TimeoutError, and OSError. Programmer errors (AttributeError, TypeError, ...)
  now propagate so misconfigured clients fail loudly.
- Log a payload-prefix preview for every silent shape branch in _extract_memories and
  _consolidate_topic so unparsable extractor output is debuggable instead of invisible.
- Restructure _run_consolidation: read maintenance state and topic snapshot under the
  state lock, run the LLM consolidation loop without holding the state lock, and only
  advance last_consolidated_at/sessions_since_consolidation if at least one topic
  succeeded. Transient consolidation failures now leave the maintenance window in
  place so the next after_run retries instead of silently sliding forward.
- Add regression tests for: markdown-marker round-trip, atomic-write recovery on
  os.replace failure, no-mkdir on pure read paths, transient consolidation failure
  preserves state, and propagation of programmer errors.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 21:19:50 +00:00
320 changed files with 36614 additions and 4757 deletions
+191 -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'
@@ -257,8 +273,11 @@ jobs:
-c ${{ matrix.configuration }} `
--no-build -v Normal `
--report-xunit-trx `
--report-junit `
--results-directory ../IntegrationTestResults/ `
--ignore-exit-code 8 `
--filter-not-trait "Category=IntegrationDisabled" `
--filter-not-trait "Category=FoundryHostedAgents" `
--parallel-algorithm aggressive `
--max-threads 2.0x
env:
@@ -277,6 +296,10 @@ jobs:
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
# Anthropic Models
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL_NAME: ${{ vars.ANTHROPIC_CHAT_MODEL_NAME }}
ANTHROPIC_REASONING_MODEL_NAME: ${{ vars.ANTHROPIC_REASONING_MODEL_NAME }}
# Generate test reports and check coverage
- name: Generate test reports
@@ -299,11 +322,117 @@ jobs:
shell: pwsh
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
- name: Upload integration test results
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
uses: actions/upload-artifact@v7
with:
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
path: IntegrationTestResults/**/*.junit
if-no-files-found: ignore
# 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.
#
# `-UsePrebuiltProjectReferences` opts into the no-rebuild fast path: publish skips
# rebuilding ProjectReferences and consumes the DLLs the prior "Build Foundry hosted IT
# (and its deps)" step already produced. This avoids MSB3026 ("file is being used by
# another process") collisions caused by the previous build's shared-compilation server
# still holding file handles to those DLLs. Safe in CI because the prebuild step ran in
# the same job against the same source. Do not remove the prebuild step (the subsequent
# `dotnet test --no-build` step depends on it too).
- 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 -UsePrebuiltProjectReferences | 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
@@ -341,3 +470,64 @@ jobs:
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
# Integration test trend report (aggregates JUnit XML results from dotnet test jobs)
dotnet-integration-test-report:
name: Integration Test Report
if: >
always() &&
github.event_name != 'pull_request' &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs: [dotnet-test]
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
.github/actions/python-setup
python
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: "3.13"
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
with:
pattern: dotnet-test-results-*
path: dotnet-test-results/
- name: Restore report history cache
uses: actions/cache/restore@v4
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
restore-keys: |
dotnet-integration-report-history-
- name: Generate trend report
run: >
uv run python scripts/integration_test_report/aggregate.py
../dotnet-test-results/
dotnet-integration-report-history.json
dotnet-integration-test-report.md
- name: Post to Job Summary
if: always()
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@v4
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
- name: Upload trend report
if: always()
uses: actions/upload-artifact@v7
with:
name: dotnet-integration-test-report
path: |
python/dotnet-integration-test-report.md
python/dotnet-integration-report-history.json
+77 -79
View File
@@ -6,8 +6,12 @@
[![MS Learn Documentation](https://img.shields.io/badge/MS%20Learn-Documentation-blue)](https://learn.microsoft.com/en-us/agent-framework/)
[![PyPI](https://img.shields.io/pypi/v/agent-framework)](https://pypi.org/project/agent-framework/)
[![NuGet](https://img.shields.io/nuget/v/Microsoft.Agents.AI)](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
[![GitHub stars](https://img.shields.io/github/stars/microsoft/agent-framework?style=social)](https://github.com/microsoft/agent-framework/stargazers)
Welcome to Microsoft's comprehensive multi-language framework for building, orchestrating, and deploying AI agents with support for both .NET and Python implementations. This framework provides everything from simple chat agents to complex multi-agent workflows with graph-based orchestration.
Microsoft Agent Framework (MAF) is an open, multi-language framework for building **production-grade AI agents and multi-agent workflows** in **.NET and Python**.
Microsoft Agent Framework is built for teams taking agents from prototype to production. It provides a consistent foundation for building, orchestrating, and operating agent systems across Python and .NET, while keeping architecture choices open as requirements evolve, and supports a broad ecosystem including Microsoft Foundry, Azure OpenAI, OpenAI, and the GitHub Copilot SDK, with samples and hosting patterns for both local development and cloud deployment.
<p align="center">
<a href="https://www.youtube.com/watch?v=AAgdMhftj8w" title="Watch the full Agent Framework introduction (30 min)">
@@ -21,10 +25,54 @@ Welcome to Microsoft's comprehensive multi-language framework for building, orch
</a>
</p>
## đź“‹ Getting Started
## Is this the right framework for you?
### 📦 Installation
MAF is a strong fit if you:
- are building agents and workflows you expect to run in production,
- need orchestration beyond a single prompt or stateless chat loop,
- want graph-based patterns such as sequential, concurrent, handoff, and group collaboration,
- care about durability, restartability, observability, governance, or human-in-the-loop control,
- need provider flexibility so your architecture can evolve without major rewrites.
## Key Features
Explore new MAF capabilities and real implementation patterns on the [official blog](https://devblogs.microsoft.com/agent-framework/).
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
- **Orchestration Patterns & Workflows**: Build multi-agent systems with graph-based workflows supporting sequential, concurrent, handoff, and group collaboration patterns; includes checkpointing, streaming, human-in-the-loop, and time-travel
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
- **Foundry Hosted Agents (new)**: Deploy and host your agents to Foundry-hosted infrastructure with just 2 additional lines of code
- [Python samples](./python/samples/04-hosting/foundry-hosted-agents/) | [.NET samples](./dotnet/samples/04-hosting/FoundryHostedAgents/)
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
- **Declarative Agents**: Define agents using YAML for faster setup and versioning
- [Declarative agent samples](./declarative-agents/)
- **Agent Skills**: Build domain-specific knowledge bases from multiple sources—files, inline code, class libraries—for agents to discover and use
- [Skills design](./docs/decisions/0021-agent-skills-design.md)
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
- [Labs directory](./python/packages/lab/)
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
- [See the DevUI in action](https://www.youtube.com/watch?v=mOAaGY4WPvc)
## Table of Contents
- [Getting Started](#getting-started)
- [Installation](#installation)
- [Learning Resources](#learning-resources)
- [Quickstart](#quickstart)
- [Basic Agent - Python](#basic-agent---python)
- [Basic Agent - .NET](#basic-agent---net)
- [More Examples & Samples](#more-examples--samples)
- [Community & Feedback](#community--feedback)
- [Troubleshooting](#troubleshooting)
- [Contributor Resources](#contributor-resources)
## Getting Started
### Installation
Python
```bash
@@ -37,9 +85,13 @@ pip install agent-framework
```bash
dotnet add package Microsoft.Agents.AI
# For Foundry integration (used in the .NET quickstart below):
dotnet add package Microsoft.Agents.AI.Foundry
dotnet add package Azure.AI.Projects
dotnet add package Azure.Identity
```
### 📚 Documentation
### Learning Resources
- **[Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)** - High level overview of the framework
- **[Quick Start](https://learn.microsoft.com/agent-framework/tutorials/quick-start)** - Get started with a simple agent
@@ -48,44 +100,9 @@ dotnet add package Microsoft.Agents.AI
- **[Migration from Semantic Kernel](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel)** - Guide to migrate from Semantic Kernel
- **[Migration from AutoGen](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen)** - Guide to migrate from AutoGen
Still have questions? Join our [weekly office hours](./COMMUNITY.md#public-community-office-hours) or ask questions in our [Discord channel](https://discord.gg/b5zjErwbQM) to get help from the team and other users.
### Quickstart
### ✨ **Highlights**
- **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, human-in-the-loop, and time-travel capabilities
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
- [Labs directory](./python/packages/lab/)
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
- [DevUI package](./python/packages/devui/)
<p align="center">
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
<img src="https://img.youtube.com/vi/mOAaGY4WPvc/hqdefault.jpg" alt="See the DevUI in action" width="480">
</a>
</p>
<p align="center">
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
See the DevUI in action (1 min)
</a>
</p>
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
### đź’¬ **We want your feedback!**
- For bugs, please file a [GitHub issue](https://github.com/microsoft/agent-framework/issues).
## Quickstart
### Basic Agent - Python
#### Basic Agent - Python
Create a simple Azure Responses Agent that writes a haiku about the Microsoft Agent Framework
@@ -109,7 +126,7 @@ async def main():
# project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
# model=os.environ["FOUNDRY_MODEL_DEPLOYMENT_NAME"],
),
name="HaikuBot",
name="HaikuAgent",
instructions="You are an upbeat assistant that writes beautifully.",
)
@@ -119,40 +136,24 @@ if __name__ == "__main__":
asyncio.run(main())
```
### Basic Agent - .NET
Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework
#### Basic Agent - .NET
Create a simple Agent, using Microsoft Foundry that writes a haiku about the Microsoft Agent Framework
```c#
// dotnet add package Microsoft.Agents.AI.Foundry
// Use `az login` to authenticate with Azure CLI
using Azure.AI.Projects;
using Azure.Identity;
using System;
// This sample shows how to create and run a basic agent with AIProjectClient.AsAIAgent(...).
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
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-5.4-mini";
var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
```
Create a simple Agent, using OpenAI Responses, that writes a haiku about the Microsoft Agent Framework
```c#
// dotnet add package Microsoft.Agents.AI.OpenAI
using System;
using OpenAI;
using OpenAI.Responses;
// Replace the <apikey> with your OpenAI API key.
var agent = new OpenAIClient("<apikey>")
.GetResponsesClient()
.AsAIAgent(model: "gpt-5.4-mini", name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
AIAgent agent =
new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(model: deploymentName, instructions: "You are an upbeat assistant that writes beautifully.", name: "HaikuAgent");
// Once you have the agent, you can invoke it like any other AIAgent.
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
```
@@ -175,6 +176,12 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows
- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos
## Community & Feedback
- **Found a bug?** File a [GitHub issue](https://github.com/microsoft/agent-framework/issues) to help us improve.
- **Enjoying MAF?** [![GitHub stars](https://img.shields.io/badge/Star-us%20on%20GitHub-yellow)](https://github.com/microsoft/agent-framework) to show your support and help others discover the project.
- **Have questions?** Join our [Discord](https://discord.gg/b5zjErwbQM) or visit [weekly office hours](./COMMUNITY.md#public-community-office-hours).
## Troubleshooting
### Authentication
@@ -187,16 +194,7 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
> **Tip:** `DefaultAzureCredential` is convenient for development but in production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
### Environment Variables
The samples typically read configuration from environment variables. Common required variables:
| Variable | Used by | Purpose |
|----------|---------|---------|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI samples | Your Azure OpenAI resource URL |
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI samples | Model deployment name (e.g. `gpt-4o-mini`) |
| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry samples | Your Microsoft Foundry project endpoint |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Microsoft Foundry samples | Model deployment name |
| `OPENAI_API_KEY` | OpenAI (non-Azure) samples | Your OpenAI platform API key |
For environment variable configuration specific to each sample, refer to the README in the sample directory ([Python samples](./python/samples/) | [.NET samples](./dotnet/samples/)).
## Contributor Resources
@@ -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.
+6 -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" />
@@ -109,6 +109,8 @@
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
<!-- Hyperlight -->
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
<!-- Inference SDKs -->
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
+1
View File
@@ -33,3 +33,4 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
- [Design Documents](../docs/design)
- [Architectural Decision Records](../docs/decisions)
- [MSFT Learn Docs](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)
+25 -1
View File
@@ -1,4 +1,4 @@
<Solution>
<Solution>
<Configurations>
<BuildType Name="Debug" />
<BuildType Name="Publish" />
@@ -175,6 +175,12 @@
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithCodeAct/">
<File Path="samples/02-agents/AgentWithCodeAct/README.md" />
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj" />
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj" />
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithMemory/">
<File Path="samples/02-agents/AgentWithMemory/README.md" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
@@ -313,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>
@@ -535,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" />
@@ -560,6 +579,7 @@
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
@@ -576,11 +596,14 @@
<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" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
@@ -606,6 +629,7 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.3.0</VersionPrefix>
<VersionPrefix>1.4.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260423</DateSuffix>
<DateSuffix>260505</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.3.0</GitTag>
<GitTag>1.4.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -50,12 +50,12 @@ Console.WriteLine(await agent.RunAsync("My name is RuaidhrĂ­", session));
Console.WriteLine(await agent.RunAsync("I am 20 years old", session));
// We can serialize the session. The serialized state will include the state of the memory component.
JsonElement sesionElement = await agent.SerializeSessionAsync(session);
JsonElement sessionElement = await agent.SerializeSessionAsync(session);
Console.WriteLine("\n>> Use deserialized session with previously created memories\n");
// Later we can deserialize the session and continue the conversation with the previous memory component state.
var deserializedSession = await agent.DeserializeSessionAsync(sesionElement);
var deserializedSession = await agent.DeserializeSessionAsync(sessionElement);
Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedSession));
Console.WriteLine("\n>> Read memories using memory component\n");
@@ -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 });
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use HyperlightCodeActProvider as a sandboxed Python
// code interpreter: the model can write and execute arbitrary Python code to
// answer quantitative questions without calling any additional tools.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hyperlight;
using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(guestPath));
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(new ChatClientAgentOptions()
{
ChatOptions = new() { Instructions = "You are a helpful assistant. When the user asks something quantitative, write Python and call `execute_code` instead of guessing." },
AIContextProviders = [codeAct],
});
Console.WriteLine(await agent.RunAsync("What is the 20th Fibonacci number?"));
Console.WriteLine(await agent.RunAsync("Compute the mean and standard deviation of [1, 4, 9, 16, 25, 36]."));
@@ -0,0 +1,35 @@
# AgentWithCodeAct_Step01_Interpreter
A minimal CodeAct sample. The agent uses `HyperlightCodeActProvider` as a
sandboxed Python interpreter: when the user asks something quantitative, the
model writes Python and invokes the `execute_code` tool rather than answering
from memory.
## Configuration
| Variable | Description |
|--------------------------------|-------------------------------------------------------------------------------------------|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
Authentication uses `DefaultAzureCredential`.
## Getting the guest module
The Python guest module is built from the
[hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox)
repository — see its README for the exact `cargo`/`just` invocations and
the location of the resulting `.wasm` / `.aot` file. Set
`HYPERLIGHT_PYTHON_GUEST_PATH` to the absolute path of that artifact
before running the sample.
Hyperlight requires a hardware virtualization back end on the host:
KVM on Linux or WHP (Windows Hypervisor Platform) on Windows.
## Run
```shell
cd AgentWithCodeAct_Step01_Interpreter
dotnet run
```
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use HyperlightCodeActProvider with provider-owned
// tools (exposed inside the sandbox via `call_tool(...)`). The model can
// orchestrate those tools in a single Python block, reducing round-trips. A
// sensitive tool (`send_email`) is additionally wrapped in
// ApprovalRequiredAIFunction so any code that reaches it requires user approval
// for the entire execute_code invocation.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hyperlight;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
AIFunction fetchDocs = AIFunctionFactory.Create(
(string topic) => $"Docs for {topic}: (...)",
name: "fetch_docs",
description: "Fetch documentation for a given topic.");
AIFunction queryData = AIFunctionFactory.Create(
(string query) => $"Rows for `{query}`: []",
name: "query_data",
description: "Run a read-only SQL-like query against the sample store.");
AIFunction sendEmail = new ApprovalRequiredAIFunction(
AIFunctionFactory.Create(
(string to, string subject) => $"Sent '{subject}' to {to}.",
name: "send_email",
description: "Send an email on behalf of the user."));
var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath);
options.Tools = [fetchDocs, queryData, sendEmail];
using var codeAct = new HyperlightCodeActProvider(options);
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(new ChatClientAgentOptions()
{
ChatOptions = new() { Instructions = "You are a helpful assistant. Prefer orchestrating your work in a single `execute_code` block using `call_tool(...)` over issuing many direct tool calls." },
AIContextProviders = [codeAct],
});
Console.WriteLine(await agent.RunAsync("Look up docs on 'retries' and query the 'orders' table, then summarize."));
@@ -0,0 +1,34 @@
# AgentWithCodeAct_Step02_ToolEnabled
Demonstrates adding provider-owned tools to `HyperlightCodeActProvider`. Those
tools are **only** available to code running inside the sandbox via
`call_tool("<name>", ...)` — they are never exposed to the model as direct
tools. This lets the model orchestrate multiple tool calls in a single Python
block.
One tool (`send_email`) is wrapped in `ApprovalRequiredAIFunction`, which causes
the entire `execute_code` invocation to require user approval when that tool
is configured.
## Configuration
| Variable | Description |
|--------------------------------|-------------------------------------------------------------------------------------------|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
## Run
```shell
cd AgentWithCodeAct_Step02_ToolEnabled
dotnet run
```
## Planned follow-up
A more realistic "upload a file (e.g. an Excel workbook), have the agent
analyze it with code" sample is planned as a separate step that will use
`HostInputDirectory` together with a guest tool capable of reading the
uploaded file. It will be added in a follow-up PR once the corresponding
guest module support is in place.
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to wire up CodeAct manually using
// HyperlightExecuteCodeFunction rather than the AIContextProvider. Use this
// when you want a fixed tool surface for the agent's lifetime and don't need
// the per-run snapshot/registry semantics of HyperlightCodeActProvider.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hyperlight;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
AIFunction calculate = AIFunctionFactory.Create(
(double a, double b) => a * b,
name: "multiply",
description: "Multiply two numbers.");
var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath);
options.Tools = [calculate];
using var executeCode = new HyperlightExecuteCodeFunction(options);
var instructions =
"You are a helpful assistant. When math is involved, solve it by writing Python "
+ "and calling `execute_code` instead of computing values yourself.\n\n"
+ executeCode.BuildInstructions(toolsVisibleToModel: false);
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(instructions: instructions, tools: [executeCode]);
Console.WriteLine(await agent.RunAsync("What is 12.3 * 4.5? Use the multiply tool from within `execute_code`."));
@@ -0,0 +1,21 @@
# AgentWithCodeAct_Step03_ManualWiring
Shows how to wire CodeAct manually using `HyperlightExecuteCodeFunction` as a
direct agent tool instead of via an `AIContextProvider`. This is useful when
the sandbox's tool surface and capabilities are fixed for the agent's
lifetime, avoiding per-run snapshot/restore of the provider registry.
## Configuration
| Variable | Description |
|--------------------------------|-------------------------------------------------------------------------------------------|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
## Run
```shell
cd AgentWithCodeAct_Step03_ManualWiring
dotnet run
```
@@ -0,0 +1,16 @@
# Agent Framework CodeAct (Hyperlight) Samples
These samples show how to enable an agent to write and execute code in a
Hyperlight-backed sandbox via the CodeAct pattern. Guest code can be pure
Python (interpreter mode) or orchestrate host-provided tools through
`call_tool(...)` — all inside a secure sandbox with opt-in filesystem and
network access.
|Sample|Description|
|---|---|
|[Code interpreter](./AgentWithCodeAct_Step01_Interpreter/)|Uses `HyperlightCodeActProvider` as a sandboxed Python interpreter with no host tools.|
|[Tool-enabled CodeAct](./AgentWithCodeAct_Step02_ToolEnabled/)|Registers provider-owned tools that guest code can orchestrate via `call_tool(...)`, with an approval-required tool for sensitive actions.|
|[Manual wiring](./AgentWithCodeAct_Step03_ManualWiring/)|Uses `HyperlightExecuteCodeFunction` directly as an agent tool when the sandbox configuration is fixed.|
All samples require a Hyperlight Python guest module. Set
`HYPERLIGHT_PYTHON_GUEST_PATH` to its absolute path before running.
@@ -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; }
}
+1
View File
@@ -11,6 +11,7 @@ The getting started samples demonstrate the fundamental concepts and functionali
| [Agent Providers](./AgentProviders/README.md) | Getting started with creating agents using various providers |
| [Agents With Retrieval Augmented Generation (RAG)](./AgentWithRAG/README.md) | Adding Retrieval Augmented Generation (RAG) capabilities to your agents |
| [Agents With Memory](./AgentWithMemory/README.md) | Adding memory capabilities to your agents |
| [Agents With CodeAct (Hyperlight)](./AgentWithCodeAct/README.md) | Enabling sandboxed code execution (CodeAct) for your agents via Hyperlight |
| [Agent Open Telemetry](./AgentOpenTelemetry/README.md) | Getting started with OpenTelemetry for agents |
| [Agent With OpenAI exchange types](./AgentWithOpenAI/README.md) | Using OpenAI exchange types with agents |
| [Agent With Anthropic](./AgentWithAnthropic/README.md) | Getting started with agents using Anthropic Claude |
@@ -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;
}
@@ -19,8 +19,7 @@ namespace Azure.AI.Projects;
/// Foundry toolbox definitions as server-side tools.
/// </summary>
/// <remarks>
/// These extensions mirror Python's <c>FoundryChatClient.get_toolbox()</c> pattern,
/// allowing a single call on the project client to retrieve tools ready for use
/// Provides a single call on the project client to retrieve tools ready for use
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
@@ -77,23 +77,31 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// 4. Convert input: history + current input → ChatMessage[]
var messages = new List<ChatMessage>();
// Load conversation history if available
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
// Load conversation history only for fresh sessions. When a session already exists
// (e.g. resuming a workflow paused at an external-input port), the workflow's
// checkpointed state already contains the prior turns' messages — replaying history
// would re-drive completed actions and break HITL resume semantics.
var isResume = !string.IsNullOrWhiteSpace(sessionConversationId)
&& session?.StateBag?.Count > 0;
if (!isResume)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history));
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
}
}
// Load and convert current input items
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
if (inputItems.Count > 0)
{
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems));
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems, session?.StateBag));
}
else
{
// Fall back to raw request input
messages.AddRange(InputConverter.ConvertInputToMessages(request));
messages.AddRange(InputConverter.ConvertInputToMessages(request, session?.StateBag));
}
// 5. Build chat options
@@ -191,6 +199,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
session?.StateBag,
cancellationToken).GetAsyncEnumerator(cancellationToken);
try
{
@@ -0,0 +1,261 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides a file-system backed implementation of <see cref="AgentSessionStore"/> that persists
/// the agent-framework's serialized <see cref="AgentSession"/> state for each (agent, conversation)
/// pair to disk. This complements Foundry storage (which owns conversation messages, agent
/// definitions, and threads) — it is not a replacement for it.
/// </summary>
/// <remarks>
/// <para>
/// The session JSON stored here is the AF runtime's own state (workflow checkpoint manager,
/// pending external requests, internal port state) that is required to resume an
/// <see cref="AgentSession"/> across HTTP requests or process restarts but is not part of
/// Foundry's data model.
/// </para>
/// <para>
/// When running in a Foundry hosted environment, sessions are stored under the well-known
/// <c>/.checkpoints</c> path; locally, they fall under <c>{cwd}/.checkpoints</c>. The session
/// JSON produced when the agent serializes the session already contains the workflow's
/// in-memory checkpoint manager state, so a single file per (agent, conversation) pair is
/// sufficient to resume long-running workflows across process restarts.
/// </para>
/// <para>
/// Files are written atomically via a temp-file + <see cref="File.Move(string, string, bool)"/>
/// rename so a partially-written file cannot be observed by a concurrent reader.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FileSystemAgentSessionStore : AgentSessionStore
{
/// <summary>
/// The well-known absolute path used when running inside a Foundry hosted environment.
/// </summary>
public const string HostedCheckpointDirectory = "/.checkpoints";
/// <summary>
/// The directory name used under the current working directory when running locally.
/// </summary>
public const string LocalCheckpointDirectoryName = ".checkpoints";
/// <summary>
/// Initializes a new instance of the <see cref="FileSystemAgentSessionStore"/> class
/// that stores serialized sessions under <paramref name="rootDirectory"/>.
/// </summary>
/// <param name="rootDirectory">
/// The absolute or relative directory where session files will be written.
/// The directory is created on first write if it does not already exist.
/// </param>
public FileSystemAgentSessionStore(string rootDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory);
this.RootDirectory = Path.GetFullPath(rootDirectory);
}
/// <summary>
/// Gets the root directory under which session files are written.
/// </summary>
public string RootDirectory { get; }
/// <summary>
/// Creates a <see cref="FileSystemAgentSessionStore"/> rooted at the default location:
/// <see cref="HostedCheckpointDirectory"/> when running in a Foundry hosted environment,
/// otherwise <see cref="LocalCheckpointDirectoryName"/> under the current working directory.
/// </summary>
/// <returns>A new <see cref="FileSystemAgentSessionStore"/> instance.</returns>
public static FileSystemAgentSessionStore CreateDefault()
{
string root = FoundryEnvironment.IsHosted
? HostedCheckpointDirectory
: Path.Combine(Environment.CurrentDirectory, LocalCheckpointDirectoryName);
return new FileSystemAgentSessionStore(root);
}
/// <inheritdoc/>
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
ArgumentNullException.ThrowIfNull(session);
JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
Directory.CreateDirectory(this.RootDirectory);
string path = this.GetSessionPath(agent, conversationId);
string? parentDir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(parentDir))
{
Directory.CreateDirectory(parentDir);
}
// Each save writes to its own temp file before atomically renaming over the
// destination. Last writer wins for the final file, but no reader can observe
// a torn or partially-written JSON document.
string tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
try
{
using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
using (Utf8JsonWriter writer = new(stream))
{
serialized.WriteTo(writer);
}
File.Move(tempPath, path, overwrite: true);
}
catch
{
try { File.Delete(tempPath); } catch { /* best-effort cleanup */ }
throw;
}
}
/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
string path = this.GetSessionPath(agent, conversationId);
if (!File.Exists(path))
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
if (bytes.Length == 0)
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
// Parse and clone so the document buffer can be released.
using JsonDocument document = JsonDocument.Parse(bytes);
JsonElement element = document.RootElement.Clone();
return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private string GetSessionPath(AIAgent agent, string conversationId)
{
// When agent.Name is set we bucket sessions into a per-agent subdirectory so
// multiple keyed agents sharing a single in-process default store cannot
// collide on the same conversationId. agent.Id is intentionally NOT used
// because it is regenerated on every startup for in-memory-defined agents.
string fileName = $"{Sanitize(conversationId)}.json";
if (string.IsNullOrEmpty(agent.Name))
{
return Path.Combine(this.RootDirectory, fileName);
}
string agentDir = Path.Combine(this.RootDirectory, Sanitize(agent.Name!));
return Path.Combine(agentDir, fileName);
}
private static string Sanitize(string value)
{
// Percent-encode every character that is invalid in a filename, plus '%' itself
// so the encoding is unambiguous. This is reversible and avoids the collision
// hazard of a lossy character substitution (e.g. "foo/bar" and "foo_bar" sharing
// a sanitized name).
char[] invalid = Path.GetInvalidFileNameChars();
int encodedLength = ComputeEncodedLength(value, invalid);
// stackalloc is bounded so an externally-controlled length cannot crash the
// hosting process with StackOverflowException.
const int StackLimit = 512;
string sanitized;
if (encodedLength <= StackLimit)
{
Span<char> buffer = stackalloc char[encodedLength];
SanitizeCore(value, invalid, buffer);
sanitized = new string(buffer);
}
else
{
char[] rented = ArrayPool<char>.Shared.Rent(encodedLength);
try
{
Span<char> buffer = rented.AsSpan(0, encodedLength);
SanitizeCore(value, invalid, buffer);
sanitized = new string(buffer);
}
finally
{
ArrayPool<char>.Shared.Return(rented);
}
}
// '.' and '..' are valid filename characters but resolve to current/parent
// directory when used as a bare path component. Windows additionally strips
// trailing dots from filenames, so a segment like "..." would survive on disk
// as "" and a partial-encode like "%2E.." would survive as "%2E". Encode every
// dot in any all-dot segment so the result has no special meaning to the OS.
if (sanitized.Length > 0 && IsAllDots(sanitized))
{
return string.Concat(Enumerable.Repeat("%2E", sanitized.Length));
}
return sanitized;
}
private static int ComputeEncodedLength(string value, char[] invalid)
{
int extra = 0;
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
{
extra += 2; // 1 char ('%' or invalid) becomes 3 chars ("%XX")
}
}
return value.Length + extra;
}
private static bool IsAllDots(string value)
{
for (int i = 0; i < value.Length; i++)
{
if (value[i] != '.')
{
return false;
}
}
return true;
}
private static void SanitizeCore(string value, char[] invalid, Span<char> buffer)
{
int j = 0;
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
{
buffer[j++] = '%';
buffer[j++] = HexChar((c >> 4) & 0xF);
buffer[j++] = HexChar(c & 0xF);
}
else
{
buffer[j++] = c;
}
}
}
private static char HexChar(int n) => (char)(n < 10 ? '0' + n : 'A' + n - 10);
}
@@ -32,9 +32,6 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
/// handles tool execution — the agent process does not invoke tools locally.
/// </para>
/// <para>
/// This is the dotnet equivalent of Python's <c>FoundryChatClient.get_toolbox()</c> pattern.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryToolbox
@@ -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
@@ -3,10 +3,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -19,14 +21,15 @@ internal static class InputConverter
/// Converts the SDK <see cref="CreateResponse"/> request input items into a list of <see cref="ChatMessage"/>.
/// </summary>
/// <param name="request">The create response request from the SDK.</param>
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
/// <returns>A list of chat messages representing the request input.</returns>
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request)
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request, AgentSessionStateBag? stateBag = null)
{
var messages = new List<ChatMessage>();
foreach (var item in request.GetInputExpanded())
{
var message = ConvertInputItemToMessage(item);
var message = ConvertInputItemToMessage(item, stateBag);
if (message is not null)
{
messages.Add(message);
@@ -40,14 +43,15 @@ internal static class InputConverter
/// Converts resolved SDK <see cref="Item"/> input items into <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="items">The resolved input items from the SDK context.</param>
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
/// <returns>A list of chat messages.</returns>
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items)
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items, AgentSessionStateBag? stateBag = null)
{
var messages = new List<ChatMessage>();
foreach (var item in items)
{
var message = ConvertInputItemToMessage(item);
var message = ConvertInputItemToMessage(item, stateBag);
if (message is not null)
{
messages.Add(message);
@@ -61,14 +65,15 @@ internal static class InputConverter
/// Converts resolved SDK <see cref="OutputItem"/> history/input items into <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="items">The resolved output items from the SDK context.</param>
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
/// <returns>A list of chat messages.</returns>
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items)
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items, AgentSessionStateBag? stateBag = null)
{
var messages = new List<ChatMessage>();
foreach (var item in items)
{
var message = ConvertOutputItemToMessage(item);
var message = ConvertOutputItemToMessage(item, stateBag);
if (message is not null)
{
messages.Add(message);
@@ -128,13 +133,15 @@ internal static class InputConverter
return markers;
}
private static ChatMessage? ConvertInputItemToMessage(Item item)
private static ChatMessage? ConvertInputItemToMessage(Item item, AgentSessionStateBag? stateBag)
{
return item switch
{
ItemMessage msg => ConvertItemMessage(msg),
FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput),
ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall),
ItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments),
MCPApprovalResponse approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag),
ItemReferenceParam => null,
_ => null
};
@@ -152,43 +159,23 @@ internal static class InputConverter
case MessageContentInputTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SdkTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SummaryTextContent summary:
contents.Add(new MeaiTextContent(summary.Text));
break;
case MessageContentReasoningTextContent reasoning:
contents.Add(new TextReasoningContent(reasoning.Text));
break;
case MessageContentInputImageContent imageContent:
if (imageContent.ImageUrl is not null)
{
var url = imageContent.ImageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(imageContent.FileId))
{
contents.Add(new HostedFileContent(imageContent.FileId));
}
AppendImageContent(contents, imageContent.ImageUrl, imageContent.FileId);
break;
case MessageContentInputFileContent fileContent:
if (fileContent.FileUrl is not null)
{
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileData))
{
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileId))
{
contents.Add(new HostedFileContent(fileContent.FileId));
}
else if (!string.IsNullOrEmpty(fileContent.Filename))
{
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
}
AppendFileContent(contents, fileContent.FileUrl, fileContent.FileData, fileContent.FileId, fileContent.Filename);
break;
case ComputerScreenshotContent screenshot:
AppendImageContent(contents, screenshot.ImageUrl, screenshot.FileId);
break;
}
}
@@ -203,7 +190,7 @@ internal static class InputConverter
private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput)
{
var output = funcOutput.Output?.ToString() ?? string.Empty;
var output = DecodeFunctionResultPayload(funcOutput.Output);
return new ChatMessage(
ChatRole.Tool,
[new FunctionResultContent(funcOutput.CallId, output)]);
@@ -231,13 +218,73 @@ internal static class InputConverter
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
}
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item)
/// <summary>
/// Converts an inbound <c>mcp_approval_request</c> wire item (from history replay
/// or fresh-input) to a <see cref="ToolApprovalRequestContent"/> wrapping a
/// <see cref="FunctionCallContent"/>.
/// </summary>
private static ChatMessage ConvertMcpApprovalRequest(string id, string name, string? arguments)
{
var functionCall = new FunctionCallContent(id, name, ParseFunctionArgumentsObject(arguments));
return new ChatMessage(
ChatRole.Assistant,
[new ToolApprovalRequestContent(id, functionCall)]);
}
/// <summary>
/// Converts an inbound <c>mcp_approval_response</c> wire item to a
/// <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 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(entry.AfRequestId, approve, functionCall)]);
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing tool-call arguments from SDK input.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing tool-call arguments from SDK input.")]
private static Dictionary<string, object?>? ParseFunctionArgumentsObject(string? arguments)
{
if (string.IsNullOrWhiteSpace(arguments))
{
return null;
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, object?>>(arguments);
}
catch (JsonException)
{
return new Dictionary<string, object?> { ["_raw"] = arguments };
}
}
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item, AgentSessionStateBag? stateBag)
{
return item switch
{
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
OutputItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments),
OutputItemMcpApprovalResponseResource approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag),
OutputItemReasoningItem => null,
_ => null
};
@@ -258,46 +305,26 @@ internal static class InputConverter
case MessageContentOutputTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SdkTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SummaryTextContent summary:
contents.Add(new MeaiTextContent(summary.Text));
break;
case MessageContentReasoningTextContent reasoning:
contents.Add(new TextReasoningContent(reasoning.Text));
break;
case MessageContentRefusalContent refusal:
contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]"));
break;
case MessageContentInputImageContent imageContent:
if (imageContent.ImageUrl is not null)
{
var url = imageContent.ImageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(imageContent.FileId))
{
contents.Add(new HostedFileContent(imageContent.FileId));
}
AppendImageContent(contents, imageContent.ImageUrl, imageContent.FileId);
break;
case MessageContentInputFileContent fileContent:
if (fileContent.FileUrl is not null)
{
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileData))
{
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileId))
{
contents.Add(new HostedFileContent(fileContent.FileId));
}
else if (!string.IsNullOrEmpty(fileContent.Filename))
{
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
}
AppendFileContent(contents, fileContent.FileUrl, fileContent.FileData, fileContent.FileId, fileContent.Filename);
break;
case ComputerScreenshotContent screenshot:
AppendImageContent(contents, screenshot.ImageUrl, screenshot.FileId);
break;
}
}
@@ -310,6 +337,127 @@ internal static class InputConverter
return new ChatMessage(role, contents);
}
private static void AppendImageContent(List<AIContent> contents, Uri? imageUrl, string? fileId)
{
if (imageUrl is not null)
{
var url = imageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(fileId))
{
contents.Add(new HostedFileContent(fileId));
}
}
private static void AppendFileContent(List<AIContent> contents, Uri? fileUrl, string? fileData, string? fileId, string? filename)
{
if (fileUrl is not null)
{
var content = new UriContent(fileUrl, "application/octet-stream");
if (!string.IsNullOrEmpty(filename))
{
content.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
}
contents.Add(content);
return;
}
if (!string.IsNullOrEmpty(fileData))
{
// If the data URI carries text/* content, decode it inline as TextContent so
// {System.LastMessageText} (and other text-only consumers) sees the file's
// body rather than an opaque blob.
if (TryDecodeTextDataUri(fileData, filename, out var decodedText))
{
contents.Add(new MeaiTextContent(decodedText));
}
else
{
var dataContent = new DataContent(fileData, "application/octet-stream");
if (!string.IsNullOrEmpty(filename))
{
dataContent.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
}
contents.Add(dataContent);
}
return;
}
if (!string.IsNullOrEmpty(fileId))
{
var hosted = new HostedFileContent(fileId);
if (!string.IsNullOrEmpty(filename))
{
hosted.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
}
contents.Add(hosted);
return;
}
if (!string.IsNullOrEmpty(filename))
{
contents.Add(new MeaiTextContent($"[File: {filename}]"));
}
}
private static bool TryDecodeTextDataUri(string dataUri, string? filename, out string text)
{
// Cap the encoded payload so an oversized client-supplied data URI cannot
// trigger an unbounded allocation in Convert.FromBase64String. 16 MiB
// encoded → ~12 MiB decoded, well above any realistic text/* file we'd
// want to inline as content while still bounding the worst case.
const int MaxEncodedLength = 16 * 1024 * 1024;
text = string.Empty;
if (!dataUri.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
return false;
}
const string Marker = ";base64,";
int markerIndex = dataUri.IndexOf(Marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0)
{
return false;
}
string mediaType = dataUri.Substring("data:".Length, markerIndex - "data:".Length);
if (!mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
string encoded = dataUri.Substring(markerIndex + Marker.Length);
if (encoded.Length > MaxEncodedLength)
{
return false;
}
try
{
byte[] bytes = Convert.FromBase64String(encoded);
string decoded = Encoding.UTF8.GetString(bytes);
text = string.IsNullOrEmpty(filename) ? decoded : $"[File: {filename}]\n{decoded}";
return true;
}
catch (FormatException)
{
return false;
}
catch (DecoderFallbackException)
{
return false;
}
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")]
private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall)
@@ -334,9 +482,54 @@ internal static class InputConverter
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
{
var output = DecodeFunctionResultPayload(funcOutput.Output);
return new ChatMessage(
ChatRole.Tool,
[new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]);
[new FunctionResultContent(funcOutput.CallId, output)]);
}
/// <summary>
/// Decodes the wire payload of a <c>function_call_output.output</c> field back into the
/// underlying tool-result text suitable for replay as <see cref="FunctionResultContent.Result"/>.
/// </summary>
/// <remarks>
/// Mirrors <c>OutputConverter.EncodeFunctionResultAsJsonStringPayload</c>. Per the OpenAI
/// Responses spec, <c>output</c> is a JSON string; we extract its underlying value. Legacy
/// producers that emitted raw JSON values (arrays/objects) are tolerated by passing the raw
/// bytes through unchanged.
/// </remarks>
private static string DecodeFunctionResultPayload(BinaryData? rawOutput)
{
if (rawOutput is null)
{
return string.Empty;
}
var raw = rawOutput.ToString();
if (string.IsNullOrEmpty(raw))
{
return string.Empty;
}
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind == JsonValueKind.String)
{
return doc.RootElement.GetString() ?? string.Empty;
}
// Legacy/non-conforming producers may have emitted a raw JSON value
// (array/object/number/bool/null). Pass the raw text through as the
// payload so the replayed FunctionResultContent.Result preserves the
// original tool output shape.
return raw;
}
catch (JsonException)
{
// Not valid JSON — treat the bytes as a literal string payload.
return raw;
}
}
private static ChatRole ConvertMessageRole(MessageRole role)
@@ -30,6 +30,7 @@ internal static class OutputConverter
/// </summary>
/// <param name="updates">The agent response updates to convert.</param>
/// <param name="stream">The SDK event stream builder.</param>
/// <param name="stateBag">Optional session state bag used to persist tool-approval id mappings across turns.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of SDK response stream events (excluding lifecycle events).</returns>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
@@ -37,6 +38,7 @@ internal static class OutputConverter
public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEventsAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
ResponseEventStream stream,
AgentSessionStateBag? stateBag = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ResponseUsage? accumulatedUsage = null;
@@ -51,8 +53,11 @@ internal static class OutputConverter
{
cancellationToken.ThrowIfCancellationRequested();
// Handle workflow events from RawRepresentation
if (update.RawRepresentation is WorkflowEvent workflowEvent)
// Handle workflow events from RawRepresentation.
// If the update also carries Contents (e.g. WorkflowSession unwrapped a
// WorkflowErrorEvent or ExecutorFailedEvent into an ErrorContent payload),
// fall through to the content-processing path below so those are emitted.
if (update.RawRepresentation is WorkflowEvent workflowEvent && update.Contents.Count == 0)
{
// Close any open message builder before emitting workflow items
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
@@ -113,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;
@@ -125,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;
}
@@ -166,6 +174,61 @@ internal static class OutputConverter
break;
}
case ToolApprovalRequestContent approvalRequest when approvalRequest.ToolCall is FunctionCallContent approvalFunctionCall:
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
previousMessageId = null;
// The Responses API only standardizes the MCP-flavored approval primitive.
// We emit the AF tool-approval request as `mcp_approval_request` with
// server_label="agent_framework" — declaring the AF runtime as the virtual
// server holding this call. The SDK requires a strict {prefix}_{50hex}
// wire-id format, so we hash the AF RequestId and persist the
// 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);
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",
approvalFunctionCall.Name,
approvalArguments);
var approvalBuilder = stream.AddOutputItem<OutputItemMcpApprovalRequest>(wireId);
yield return approvalBuilder.EmitAdded(approvalItem);
yield return approvalBuilder.EmitDone(approvalItem);
break;
}
case ToolApprovalRequestContent:
// Approval requests must wrap a FunctionCallContent (handled above).
// Any other shape has no representation in the Responses wire format.
break;
case ToolApprovalResponseContent:
// Approval responses originate from the client and travel inbound; the
// workflow does not re-emit them. Skip silently if encountered.
break;
case UsageContent usageContent when usageContent.Details is not null:
{
accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage);
@@ -199,10 +262,35 @@ 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 = EncodeFunctionResultAsJsonStringPayload(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;
@@ -355,4 +443,44 @@ internal static class OutputConverter
var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase
return $"{prefix}_{body}";
}
/// <summary>
/// Encodes a <see cref="FunctionResultContent.Result"/> value into the wire payload for
/// the OpenAI Responses <c>function_call_output.output</c> field.
/// </summary>
/// <remarks>
/// The OpenAI Responses spec requires <c>output</c> to be a JSON string. The Responses
/// SDK's <see cref="OutputItemFunctionToolCallOutput"/> accepts a <see cref="BinaryData"/>
/// containing the *raw JSON value* for the field, so the returned text is always a JSON
/// string literal (quoted, with escapes). This avoids two bugs:
/// <list type="bullet">
/// <item>Complex results (e.g. <c>List&lt;TodoItem&gt;</c>) landing on the wire as an
/// unquoted JSON array, which the strict-parsing OpenAI .NET client
/// (<c>FunctionCallOutputResponseItem</c>) rejects with
/// "requires an element of type 'String', but the target element has type 'Array'".</item>
/// <item>Numeric- or JSON-shaped string results (e.g. <c>"42"</c> or <c>"{\"k\":1}"</c>)
/// silently changing type on the wire because <c>BinaryData</c> auto-detects JSON.</item>
/// </list>
/// <see cref="JsonElement"/> / <see cref="JsonDocument"/> values are unwrapped first so
/// a string-kind element does not get double-encoded into <c>"\"value\""</c>.
/// </remarks>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call result payload.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call result payload.")]
private static string EncodeFunctionResultAsJsonStringPayload(object? result)
{
string innerText = result switch
{
null => string.Empty,
string s => s,
JsonElement je => je.ValueKind == JsonValueKind.String
? (je.GetString() ?? string.Empty)
: je.GetRawText(),
JsonDocument jd => jd.RootElement.ValueKind == JsonValueKind.String
? (jd.RootElement.GetString() ?? string.Empty)
: jd.RootElement.GetRawText(),
_ => JsonSerializer.Serialize(result),
};
return JsonSerializer.Serialize(innerText);
}
}
@@ -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;
@@ -49,7 +49,7 @@ public static class FoundryHostingExtensions
{
ArgumentNullException.ThrowIfNull(services);
services.AddResponsesServer();
services.TryAddSingleton<AgentSessionStore, InMemoryAgentSessionStore>();
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
@@ -76,7 +76,7 @@ public static class FoundryHostingExtensions
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="agent">The agent instance to register.</param>
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used.</param>
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at <c>/.checkpoints</c> when running in a Foundry hosted environment and <c>{cwd}/.checkpoints</c> locally.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
{
@@ -84,7 +84,7 @@ public static class FoundryHostingExtensions
ArgumentNullException.ThrowIfNull(agent);
services.AddResponsesServer();
agentSessionStore ??= new InMemoryAgentSessionStore();
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
if (!string.IsNullOrWhiteSpace(agent.Name))
{
@@ -185,8 +185,6 @@ public static class FoundryHostingExtensions
/// <summary>
/// The ActivitySource name for the Responses hosting pipeline.
/// Matches the value previously exposed by <c>AgentHostTelemetry.ResponsesSourceName</c>
/// in <c>Azure.AI.AgentServer.Core</c>.
/// </summary>
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
@@ -209,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();
}
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft. All rights reserved.
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, 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 ↔ 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)
/// for deterministic, format-safe wire ids.
/// </summary>
public static string ComputeWireId(string afRequestId)
{
ArgumentNullException.ThrowIfNull(afRequestId);
#if NET10_0_OR_GREATER
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId), hash);
#else
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId));
#endif
// 25 bytes = 50 hex chars (matches SDK body length 50).
return "mcpr_" + Convert.ToHexString(hash).AsSpan(0, 50).ToString();
}
/// <summary>
/// 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, string? callId, string? name, string? argumentsJson)
{
if (stateBag is null)
{
return;
}
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.
/// </summary>
public static string Resolve(AgentSessionStateBag? stateBag, string wireId)
{
if (TryLoadMap(stateBag, out var map)
&& map.TryGetValue(wireId, out var entry))
{
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)
@@ -13,5 +13,5 @@ internal sealed class SequenceNumber
/// Gets the next sequence number.
/// </summary>
/// <returns>The next sequence number.</returns>
public int Increment() => this._sequenceNumber++;
public int Increment() => System.Threading.Interlocked.Increment(ref this._sequenceNumber) - 1;
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Hyperlight;
/// <summary>
/// Represents a single entry in the outbound network allow-list applied to the
/// Hyperlight sandbox.
/// </summary>
public sealed class AllowedDomain
{
/// <summary>
/// Initializes a new instance of the <see cref="AllowedDomain"/> class.
/// </summary>
/// <param name="target">URL or domain to allow, for example <c>"https://api.github.com"</c>.</param>
/// <param name="methods">
/// Optional list of HTTP methods to allow (for example <c>["GET", "POST"]</c>).
/// When <see langword="null"/>, all methods supported by the backend are allowed.
/// </param>
public AllowedDomain(string target, IReadOnlyList<string>? methods = null)
{
this.Target = target;
this.Methods = methods;
}
/// <summary>Gets the URL or domain to allow.</summary>
public string Target { get; }
/// <summary>Gets the optional list of HTTP methods to allow.</summary>
public IReadOnlyList<string>? Methods { get; }
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight;
/// <summary>
/// Controls the approval behavior for the <c>execute_code</c> tool exposed by
/// <see cref="HyperlightCodeActProvider"/> and <see cref="HyperlightExecuteCodeFunction"/>.
/// </summary>
public enum CodeActApprovalMode
{
/// <summary>
/// <c>execute_code</c> always requires user approval before invocation.
/// </summary>
AlwaysRequire,
/// <summary>
/// Approval is derived from the provider-owned CodeAct tool registry.
/// If any configured tool is an
/// <see cref="ApprovalRequiredAIFunction"/>,
/// <c>execute_code</c> also requires approval. Otherwise it does not.
/// </summary>
NeverRequire,
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Hyperlight;
/// <summary>
/// Represents a host-to-sandbox file mount configuration used by
/// <see cref="HyperlightCodeActProvider"/>.
/// </summary>
public sealed class FileMount
{
/// <summary>
/// Initializes a new instance of the <see cref="FileMount"/> class.
/// </summary>
/// <param name="hostPath">Absolute or relative path on the host filesystem to mount into the sandbox.</param>
/// <param name="mountPath">
/// Path inside the sandbox the host path is exposed at (for example <c>"/input/data.csv"</c>).
/// </param>
public FileMount(string hostPath, string mountPath)
{
this.HostPath = hostPath;
this.MountPath = mountPath;
}
/// <summary>Gets the path on the host filesystem that is mounted into the sandbox.</summary>
public string HostPath { get; }
/// <summary>Gets the path inside the sandbox at which the host path is exposed.</summary>
public string MountPath { get; }
}
@@ -0,0 +1,324 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hyperlight.Internal;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hyperlight;
/// <summary>
/// An <see cref="AIContextProvider"/> that enables CodeAct execution through a
/// Hyperlight-backed sandbox.
/// </summary>
/// <remarks>
/// <para>
/// The provider injects an <c>execute_code</c> tool into the model-facing tool
/// surface and contributes a short CodeAct guidance block through
/// <see cref="AIContext.Instructions"/>. Guest code executed via
/// <c>execute_code</c> runs in an isolated Hyperlight sandbox with
/// snapshot/restore for clean state per invocation.
/// </para>
/// <para>
/// If no CodeAct-managed tools are configured the provider behaves as a code
/// interpreter. If one or more tools are configured they are exposed to guest
/// code via <c>call_tool(...)</c> but not to the model directly.
/// </para>
/// <para>
/// Only a single <see cref="HyperlightCodeActProvider"/> may be attached to a
/// given agent. <see cref="StateKeys"/> returns a fixed value so
/// <c>ChatClientAgent</c>'s state-key uniqueness validation rejects duplicate
/// registrations.
/// </para>
/// <para>
/// <strong>Security considerations:</strong> guest code runs with only the
/// capabilities explicitly configured on this provider (file mounts, allowed
/// outbound domains). Callers should configure the smallest capability set
/// sufficient for the task and consider using
/// <see cref="CodeActApprovalMode.AlwaysRequire"/> when guest code can reach
/// sensitive resources.
/// </para>
/// </remarks>
public sealed class HyperlightCodeActProvider : AIContextProvider, IDisposable
{
/// <summary>
/// Fixed state key used to enforce a single provider-per-agent.
/// </summary>
internal const string FixedStateKey = "HyperlightCodeActProvider";
private static readonly IReadOnlyList<string> s_stateKeys = [FixedStateKey];
private readonly object _gate = new();
private readonly HyperlightCodeActProviderOptions _options;
private readonly SandboxExecutor _executor;
private readonly Dictionary<string, AIFunction> _tools = new(StringComparer.Ordinal);
private readonly Dictionary<string, FileMount> _fileMounts = new(StringComparer.Ordinal);
private readonly Dictionary<string, AllowedDomain> _allowedDomains = new(StringComparer.Ordinal);
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="HyperlightCodeActProvider"/> class.
/// </summary>
/// <param name="options">
/// Optional configuration options for the provider. When <see langword="null"/> the provider
/// uses the defaults of <see cref="HyperlightCodeActProviderOptions"/> (the
/// <see cref="HyperlightSandbox.Api.SandboxBackend.JavaScript"/> backend with no tools, mounts, or allow-list entries).
/// Use <see cref="HyperlightCodeActProviderOptions.CreateForWasm(string)"/> to target a Wasm
/// guest module instead.
/// </param>
public HyperlightCodeActProvider(HyperlightCodeActProviderOptions? options = null)
{
this._options = options ?? new HyperlightCodeActProviderOptions();
this._executor = new SandboxExecutor(this._options);
if (this._options.Tools is not null)
{
foreach (var tool in this._options.Tools.Where(t => t is not null))
{
this._tools[tool.Name] = tool;
}
}
if (this._options.FileMounts is not null)
{
foreach (var mount in this._options.FileMounts.Where(m => m is not null))
{
this._fileMounts[mount.MountPath] = mount;
}
}
if (this._options.AllowedDomains is not null)
{
foreach (var domain in this._options.AllowedDomains.Where(d => d is not null))
{
this._allowedDomains[domain.Target] = domain;
}
}
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => s_stateKeys;
// -------------------------------------------------------------------
// Tool registry
// -------------------------------------------------------------------
/// <summary>Adds tools to the provider-owned CodeAct tool registry. Tools with a duplicate name replace the existing registration.</summary>
/// <param name="tools">The tools to add.</param>
public void AddTools(params AIFunction[] tools)
{
_ = Throw.IfNull(tools);
lock (this._gate)
{
this.ThrowIfDisposed();
foreach (var tool in tools.Where(t => t is not null))
{
this._tools[tool.Name] = tool;
}
}
}
/// <summary>Returns the current CodeAct-managed tools.</summary>
public IReadOnlyList<AIFunction> GetTools()
{
lock (this._gate)
{
return this._tools.Values.ToList();
}
}
/// <summary>Removes tools by name from the CodeAct tool registry.</summary>
/// <param name="names">The names of the tools to remove.</param>
public void RemoveTools(params string[] names)
{
_ = Throw.IfNull(names);
lock (this._gate)
{
foreach (var name in names.Where(n => n is not null))
{
_ = this._tools.Remove(name);
}
}
}
/// <summary>Removes all CodeAct-managed tools.</summary>
public void ClearTools()
{
lock (this._gate)
{
this._tools.Clear();
}
}
// -------------------------------------------------------------------
// File mounts
// -------------------------------------------------------------------
/// <summary>Adds file mount configurations. Mounts with a duplicate mount path replace the existing entry.</summary>
/// <param name="mounts">The mount configurations to add.</param>
public void AddFileMounts(params FileMount[] mounts)
{
_ = Throw.IfNull(mounts);
lock (this._gate)
{
foreach (var mount in mounts.Where(m => m is not null))
{
this._fileMounts[mount.MountPath] = mount;
}
}
}
/// <summary>Returns the current file mount configurations.</summary>
public IReadOnlyList<FileMount> GetFileMounts()
{
lock (this._gate)
{
return this._fileMounts.Values.ToList();
}
}
/// <summary>Removes file mounts by sandbox mount path.</summary>
/// <param name="mountPaths">The mount paths to remove.</param>
public void RemoveFileMounts(params string[] mountPaths)
{
_ = Throw.IfNull(mountPaths);
lock (this._gate)
{
foreach (var path in mountPaths.Where(p => p is not null))
{
_ = this._fileMounts.Remove(path);
}
}
}
/// <summary>Removes all file mount configurations.</summary>
public void ClearFileMounts()
{
lock (this._gate)
{
this._fileMounts.Clear();
}
}
// -------------------------------------------------------------------
// Network allow-list
// -------------------------------------------------------------------
/// <summary>Adds outbound network allow-list entries. Entries with a duplicate target replace the existing entry.</summary>
/// <param name="domains">The allow-list entries to add.</param>
public void AddAllowedDomains(params AllowedDomain[] domains)
{
_ = Throw.IfNull(domains);
lock (this._gate)
{
foreach (var domain in domains.Where(d => d is not null))
{
this._allowedDomains[domain.Target] = domain;
}
}
}
/// <summary>Returns the current outbound allow-list entries.</summary>
public IReadOnlyList<AllowedDomain> GetAllowedDomains()
{
lock (this._gate)
{
return this._allowedDomains.Values.ToList();
}
}
/// <summary>Removes allow-list entries by target.</summary>
/// <param name="targets">The targets to remove.</param>
public void RemoveAllowedDomains(params string[] targets)
{
_ = Throw.IfNull(targets);
lock (this._gate)
{
foreach (var target in targets.Where(t => t is not null))
{
_ = this._allowedDomains.Remove(target);
}
}
}
/// <summary>Removes all outbound allow-list entries.</summary>
public void ClearAllowedDomains()
{
lock (this._gate)
{
this._allowedDomains.Clear();
}
}
// -------------------------------------------------------------------
// AIContextProvider implementation
// -------------------------------------------------------------------
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
SandboxExecutor.RunSnapshot snapshot;
lock (this._gate)
{
this.ThrowIfDisposed();
snapshot = new SandboxExecutor.RunSnapshot(
this._tools.Values.ToList(),
this._fileMounts.Values.ToList(),
this._allowedDomains.Values.ToList(),
this._options.HostInputDirectory);
}
var approvalRequired = ComputeApprovalRequired(this._options.ApprovalMode, snapshot.Tools);
var description = InstructionBuilder.BuildExecuteCodeDescription(
snapshot.Tools,
snapshot.FileMounts,
snapshot.AllowedDomains,
hasHostInputDirectory: !string.IsNullOrEmpty(snapshot.HostInputDirectory));
AIFunction executeCode = new ExecuteCodeFunction(this._executor, snapshot, description);
if (approvalRequired)
{
executeCode = new ApprovalRequiredAIFunction(executeCode);
}
var instructions = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: false);
var result = new AIContext
{
Instructions = instructions,
Tools = [executeCode],
};
return new ValueTask<AIContext>(result);
}
internal static bool ComputeApprovalRequired(CodeActApprovalMode mode, IReadOnlyList<AIFunction> tools) =>
mode == CodeActApprovalMode.AlwaysRequire
|| tools.Any(t => t.GetService<ApprovalRequiredAIFunction>() is not null);
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this);
/// <summary>Releases the underlying sandbox and associated native resources.</summary>
public void Dispose()
{
lock (this._gate)
{
if (this._disposed)
{
return;
}
this._disposed = true;
}
this._executor.Dispose();
}
}
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using HyperlightSandbox.Api;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hyperlight;
/// <summary>
/// Configuration options for <see cref="HyperlightCodeActProvider"/> and
/// <see cref="HyperlightExecuteCodeFunction"/>.
/// </summary>
/// <remarks>
/// Use the <see cref="CreateForWasm(string)"/> and <see cref="CreateForJavaScript()"/>
/// factory methods to construct an instance with the desired sandbox backend.
/// The parameterless constructor is equivalent to <see cref="CreateForJavaScript()"/>.
/// </remarks>
public sealed class HyperlightCodeActProviderOptions
{
/// <summary>
/// Initializes a new instance configured for the JavaScript backend.
/// Equivalent to <see cref="CreateForJavaScript()"/>.
/// </summary>
public HyperlightCodeActProviderOptions()
: this(SandboxBackend.JavaScript, modulePath: null)
{
}
private HyperlightCodeActProviderOptions(SandboxBackend backend, string? modulePath)
{
this.Backend = backend;
this.ModulePath = modulePath;
}
/// <summary>
/// Creates options targeting the <see cref="SandboxBackend.Wasm"/> backend.
/// </summary>
/// <param name="modulePath">Path to the guest module (<c>.wasm</c> or <c>.aot</c> file).</param>
public static HyperlightCodeActProviderOptions CreateForWasm(string modulePath)
=> new(SandboxBackend.Wasm, Throw.IfNullOrWhitespace(modulePath));
/// <summary>
/// Creates options targeting the <see cref="SandboxBackend.JavaScript"/> backend.
/// </summary>
public static HyperlightCodeActProviderOptions CreateForJavaScript()
=> new(SandboxBackend.JavaScript, modulePath: null);
/// <summary>
/// Gets the Hyperlight sandbox backend this options instance is configured for.
/// </summary>
public SandboxBackend Backend { get; }
/// <summary>
/// Gets the path to the guest module. Set when the options were created via
/// <see cref="CreateForWasm(string)"/>; <see langword="null"/> otherwise.
/// </summary>
public string? ModulePath { get; }
/// <summary>
/// Gets or sets the guest heap size. Accepts human-readable strings such as
/// <c>"50Mi"</c> or <c>"2Gi"</c>. When <see langword="null"/> the backend default is used.
/// </summary>
public string? HeapSize { get; set; }
/// <summary>
/// Gets or sets the guest stack size. Accepts human-readable strings such as
/// <c>"35Mi"</c>. When <see langword="null"/> the backend default is used.
/// </summary>
public string? StackSize { get; set; }
/// <summary>
/// Gets or sets the initial set of provider-owned CodeAct tools made available
/// inside the sandbox via <c>call_tool(...)</c>.
/// </summary>
public IEnumerable<AIFunction>? Tools { get; set; }
/// <summary>
/// Gets or sets the default approval mode for <c>execute_code</c>.
/// Defaults to <see cref="CodeActApprovalMode.NeverRequire"/>.
/// </summary>
public CodeActApprovalMode ApprovalMode { get; set; } = CodeActApprovalMode.NeverRequire;
/// <summary>
/// Gets or sets an optional host directory exposed to the sandbox as its
/// <c>/input</c> directory.
/// </summary>
public string? HostInputDirectory { get; set; }
/// <summary>
/// Gets or sets the initial set of file mount configurations.
/// </summary>
public IEnumerable<FileMount>? FileMounts { get; set; }
/// <summary>
/// Gets or sets the initial outbound network allow-list entries.
/// </summary>
public IEnumerable<AllowedDomain>? AllowedDomains { get; set; }
}
@@ -0,0 +1,162 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hyperlight.Internal;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight;
/// <summary>
/// Standalone <c>execute_code</c> <see cref="AIFunction"/> backed by a
/// Hyperlight sandbox. Use this for manual/static wiring when an
/// <see cref="AIContextProvider"/> lifecycle is not needed — for example
/// when the tool registry and capability configuration are fixed for the
/// lifetime of the agent.
/// </summary>
/// <remarks>
/// Unlike <see cref="HyperlightCodeActProvider"/>, this type does not hook
/// into the <see cref="AIContextProvider"/> pipeline. It captures a single
/// snapshot of the provided <see cref="HyperlightCodeActProviderOptions"/>
/// at construction time and reuses it for the lifetime of the instance.
/// The instance can be passed directly anywhere an <see cref="AIFunction"/>
/// is accepted; when the configuration requires approval (per
/// <see cref="HyperlightCodeActProviderOptions.ApprovalMode"/> or because a
/// configured tool is itself an <see cref="ApprovalRequiredAIFunction"/>),
/// the instance surfaces an <see cref="ApprovalRequiredAIFunction"/> via
/// <see cref="AITool.GetService(Type, object?)"/>, which is how the rest of
/// the framework discovers approval requirements.
/// </remarks>
public sealed class HyperlightExecuteCodeFunction : AIFunction, IDisposable
{
private const string ExecuteCodeName = "execute_code";
private static readonly JsonElement s_schema = JsonDocument.Parse(
"""
{
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Code to execute using the provider's configured backend/runtime behavior."
}
},
"required": ["code"]
}
""").RootElement;
private readonly SandboxExecutor _executor;
private readonly SandboxExecutor.RunSnapshot _snapshot;
private readonly string _description;
private readonly bool _approvalRequired;
private ApprovalRequiredAIFunction? _approvalProxy;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="HyperlightExecuteCodeFunction"/> class.
/// </summary>
/// <param name="options">
/// Optional configuration options. When <see langword="null"/> the defaults of
/// <see cref="HyperlightCodeActProviderOptions"/> are used.
/// </param>
public HyperlightExecuteCodeFunction(HyperlightCodeActProviderOptions? options = null)
{
var effective = options ?? new HyperlightCodeActProviderOptions();
this._executor = new SandboxExecutor(effective);
var tools = (effective.Tools?.Where(t => t is not null) ?? []).ToList();
var fileMounts = (effective.FileMounts?.Where(m => m is not null) ?? []).ToList();
var allowedDomains = (effective.AllowedDomains?.Where(d => d is not null) ?? []).ToList();
this._snapshot = new SandboxExecutor.RunSnapshot(tools, fileMounts, allowedDomains, effective.HostInputDirectory);
this._description = InstructionBuilder.BuildExecuteCodeDescription(
this._snapshot.Tools,
this._snapshot.FileMounts,
this._snapshot.AllowedDomains,
hasHostInputDirectory: !string.IsNullOrEmpty(this._snapshot.HostInputDirectory));
this._approvalRequired = HyperlightCodeActProvider.ComputeApprovalRequired(effective.ApprovalMode, this._snapshot.Tools);
}
/// <inheritdoc />
public override string Name => ExecuteCodeName;
/// <inheritdoc />
public override string Description => this._description;
/// <inheritdoc />
public override JsonElement JsonSchema => s_schema;
/// <summary>
/// Builds a CodeAct instruction string describing the available tools and capabilities.
/// </summary>
/// <param name="toolsVisibleToModel">
/// When <see langword="false"/>, the instructions assume tools are only accessible
/// through CodeAct (via <c>call_tool</c>). When <see langword="true"/>, the instructions
/// are abbreviated for cases where the same tools are already visible to the model as
/// direct agent tools.
/// </param>
public string BuildInstructions(bool toolsVisibleToModel = false)
{
this.ThrowIfDisposed();
return InstructionBuilder.BuildContextInstructions(toolsVisibleToModel);
}
/// <inheritdoc />
public override object? GetService(Type serviceType, object? serviceKey = null)
{
if (serviceKey is null
&& this._approvalRequired
&& serviceType == typeof(ApprovalRequiredAIFunction))
{
return this._approvalProxy ??= new ApprovalRequiredAIFunction(this);
}
return base.GetService(serviceType, serviceKey);
}
/// <inheritdoc />
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments,
CancellationToken cancellationToken)
{
this.ThrowIfDisposed();
if (arguments is null || !arguments.TryGetValue("code", out var codeObj) || codeObj is null)
{
throw new ArgumentException("Missing required parameter 'code'.", nameof(arguments));
}
var code = codeObj switch
{
string s => s,
JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty,
_ => codeObj.ToString() ?? string.Empty,
};
if (string.IsNullOrWhiteSpace(code))
{
throw new ArgumentException("Parameter 'code' must not be empty.", nameof(arguments));
}
return await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false);
}
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this);
/// <summary>Releases the underlying sandbox and associated native resources.</summary>
public void Dispose()
{
if (this._disposed)
{
return;
}
this._disposed = true;
this._executor.Dispose();
}
}
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight.Internal;
/// <summary>
/// Run-scoped <see cref="AIFunction"/> that exposes <c>execute_code</c>
/// to the model. The function closes over an immutable
/// <see cref="SandboxExecutor.RunSnapshot"/> captured at the start of the
/// agent invocation, so subsequent CRUD mutations on the provider do not
/// affect an in-flight run.
/// </summary>
internal sealed class ExecuteCodeFunction : AIFunction
{
private const string ExecuteCodeName = "execute_code";
private static readonly JsonElement s_schema = JsonDocument.Parse(
"""
{
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Code to execute using the provider's configured backend/runtime behavior."
}
},
"required": ["code"]
}
""").RootElement;
private readonly SandboxExecutor _executor;
private readonly SandboxExecutor.RunSnapshot _snapshot;
private readonly string _description;
public ExecuteCodeFunction(
SandboxExecutor executor,
SandboxExecutor.RunSnapshot snapshot,
string description)
{
this._executor = executor;
this._snapshot = snapshot;
this._description = description;
}
/// <inheritdoc />
public override string Name => ExecuteCodeName;
/// <inheritdoc />
public override string Description => this._description;
/// <inheritdoc />
public override JsonElement JsonSchema => s_schema;
/// <inheritdoc />
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments,
CancellationToken cancellationToken)
{
if (arguments is null || !arguments.TryGetValue("code", out var codeObj) || codeObj is null)
{
throw new ArgumentException("Missing required parameter 'code'.", nameof(arguments));
}
var code = codeObj switch
{
string s => s,
JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty,
_ => codeObj.ToString() ?? string.Empty,
};
if (string.IsNullOrWhiteSpace(code))
{
throw new ArgumentException("Parameter 'code' must not be empty.", nameof(arguments));
}
return await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hyperlight.Internal;
/// <summary>
/// Source-generated JSON context for the well-known envelope shapes the Hyperlight
/// integration serializes (the execute_code result payload and the tool error payload).
/// User-supplied tool results are serialized via AIJsonUtilities.DefaultOptions instead
/// because their types cannot be statically known at compile time.
/// </summary>
[JsonSourceGenerationOptions(JsonSerializerDefaults.General)]
[JsonSerializable(typeof(HyperlightExecutionResult))]
[JsonSerializable(typeof(HyperlightToolError))]
internal sealed partial class HyperlightJsonContext : JsonSerializerContext;
internal sealed record HyperlightExecutionResult(
[property: JsonPropertyName("stdout")] string Stdout,
[property: JsonPropertyName("stderr")] string Stderr,
[property: JsonPropertyName("exit_code")] int ExitCode,
[property: JsonPropertyName("success")] bool Success);
internal sealed record HyperlightToolError(
[property: JsonPropertyName("error")] string Error);
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight.Internal;
/// <summary>
/// Builds the CodeAct guidance strings returned through
/// <see cref="AIContext.Instructions"/> and the <c>execute_code</c>
/// function description.
/// </summary>
internal static class InstructionBuilder
{
/// <summary>
/// Builds the short CodeAct guidance block that is merged into the
/// agent's instructions for the current invocation.
/// </summary>
public static string BuildContextInstructions(bool toolsVisibleToModel)
{
if (toolsVisibleToModel)
{
return
"You can execute code in a secure sandbox by calling the `execute_code` tool. "
+ "Use it for calculations, data analysis, and anything that benefits from running code. "
+ "State does not persist between calls; pass any required values in the code you execute.";
}
return
"You can execute code in a secure sandbox by calling the `execute_code` tool. "
+ "Any tools listed in the tool's description are only accessible from within the sandbox "
+ "via `call_tool(\"<name>\", ...)` — they cannot be invoked directly. "
+ "State does not persist between calls; pass any required values in the code you execute.";
}
/// <summary>
/// Builds the detailed description attached to the run-scoped
/// <c>execute_code</c> <see cref="AIFunction"/>. This includes the
/// available <c>call_tool</c> signatures and a capability summary.
/// </summary>
/// <remarks>
/// Host-side filesystem paths are intentionally omitted from the
/// description — only sandbox-visible mount paths are exposed to the
/// model.
/// </remarks>
public static string BuildExecuteCodeDescription(
IReadOnlyList<AIFunction> tools,
IReadOnlyList<FileMount> fileMounts,
IReadOnlyList<AllowedDomain> allowedDomains,
bool hasHostInputDirectory)
{
var sb = new StringBuilder();
sb.Append("Executes code in a secure Hyperlight sandbox. ");
sb.Append("Pass the full source to execute via the `code` parameter. ");
sb.Append("Returns a JSON string with `stdout`, `stderr`, `exit_code`, and `success` fields.");
if (tools.Count > 0)
{
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("The following host tools are available inside the sandbox via `call_tool(\"<name>\", **kwargs)`:");
foreach (var tool in tools)
{
sb.Append("- `");
sb.Append(tool.Name);
sb.Append('`');
if (!string.IsNullOrWhiteSpace(tool.Description))
{
sb.Append(": ");
sb.Append(tool.Description);
}
sb.AppendLine();
}
}
if (hasHostInputDirectory || fileMounts.Count > 0)
{
sb.AppendLine();
sb.AppendLine("Filesystem access:");
if (hasHostInputDirectory)
{
sb.AppendLine("- Host input directory mounted read-only at `/input`.");
}
foreach (var mount in fileMounts)
{
sb.Append("- `");
sb.Append(mount.MountPath);
sb.AppendLine("`");
}
}
if (allowedDomains.Count > 0)
{
sb.AppendLine();
sb.AppendLine("Outbound network access is restricted to the following targets:");
foreach (var domain in allowedDomains)
{
sb.Append("- `");
sb.Append(domain.Target);
sb.Append('`');
if (domain.Methods is { Count: > 0 })
{
sb.Append(" [");
sb.Append(string.Join(", ", domain.Methods));
sb.Append(']');
}
sb.AppendLine();
}
}
return sb.ToString().TrimEnd();
}
}
@@ -0,0 +1,243 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using HyperlightSandbox.Api;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight.Internal;
/// <summary>
/// Captures a per-run snapshot of the provider state and owns the
/// lifecycle of the underlying <see cref="Sandbox"/>. A single
/// <see cref="SandboxExecutor"/> is shared across runs and serializes
/// execution via snapshot/restore.
/// </summary>
internal sealed class SandboxExecutor : IDisposable
{
private readonly HyperlightCodeActProviderOptions _options;
private readonly SemaphoreSlim _executionLock = new(1, 1);
private Sandbox? _sandbox;
private SandboxSnapshot? _warmSnapshot;
private string? _lastConfigFingerprint;
private bool _disposed;
public SandboxExecutor(HyperlightCodeActProviderOptions options)
{
this._options = options;
}
/// <summary>
/// Immutable snapshot of provider state at the start of a run.
/// Used to build a run-scoped <c>execute_code</c> function that is
/// independent of subsequent CRUD mutations.
/// </summary>
internal sealed class RunSnapshot
{
public RunSnapshot(
IReadOnlyList<AIFunction> tools,
IReadOnlyList<FileMount> fileMounts,
IReadOnlyList<AllowedDomain> allowedDomains,
string? hostInputDirectory)
{
this.Tools = tools;
this.FileMounts = fileMounts;
this.AllowedDomains = allowedDomains;
this.HostInputDirectory = hostInputDirectory;
this.ConfigFingerprint = ComputeFingerprint(tools, fileMounts, allowedDomains, hostInputDirectory);
}
public IReadOnlyList<AIFunction> Tools { get; }
public IReadOnlyList<FileMount> FileMounts { get; }
public IReadOnlyList<AllowedDomain> AllowedDomains { get; }
public string? HostInputDirectory { get; }
/// <summary>
/// Stable fingerprint of the configuration that materially affects how
/// the sandbox must be built. Used by <see cref="SandboxExecutor"/> to
/// decide whether a previously-built sandbox can be reused or must be
/// rebuilt because tools / mounts / allow-list entries have changed.
/// </summary>
public string ConfigFingerprint { get; }
internal static string ComputeFingerprint(
IReadOnlyList<AIFunction> tools,
IReadOnlyList<FileMount> fileMounts,
IReadOnlyList<AllowedDomain> allowedDomains,
string? hostInputDirectory)
{
var sb = new StringBuilder();
sb.Append("tools=");
foreach (var name in tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal))
{
sb.Append(name).Append('|');
}
sb.Append(";mounts=");
foreach (var m in fileMounts
.Select(m => m.MountPath + "->" + m.HostPath)
.OrderBy(s => s, StringComparer.Ordinal))
{
sb.Append(m).Append('|');
}
sb.Append(";allow=");
foreach (var d in allowedDomains
.Select(d => d.Target + "/" + (d.Methods is null ? "*" : string.Join(",", d.Methods)))
.OrderBy(s => s, StringComparer.Ordinal))
{
sb.Append(d).Append('|');
}
sb.Append(";input=").Append(hostInputDirectory ?? string.Empty);
return sb.ToString();
}
}
/// <summary>
/// Executes <paramref name="code"/> inside the sandbox using the
/// captured <paramref name="snapshot"/>. Builds (or rebuilds) the
/// sandbox lazily when the snapshot's configuration fingerprint
/// differs from the previously-used one.
/// </summary>
public async Task<string> ExecuteAsync(RunSnapshot snapshot, string code, CancellationToken cancellationToken)
{
await this._executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
this.EnsureInitialized(snapshot);
if (this._warmSnapshot is not null)
{
this._sandbox!.Restore(this._warmSnapshot);
}
ExecutionResult result;
try
{
result = this._sandbox!.Run(code);
}
#pragma warning disable CA1031 // Surface sandbox execution failures as structured JSON rather than propagating.
catch (Exception ex)
#pragma warning restore CA1031
{
return BuildErrorResult(ex.Message);
}
return BuildResult(result);
}
finally
{
this._executionLock.Release();
}
}
private void EnsureInitialized(RunSnapshot snapshot)
{
if (this._sandbox is not null && string.Equals(this._lastConfigFingerprint, snapshot.ConfigFingerprint, StringComparison.Ordinal))
{
return;
}
// Configuration changed (or first run) — dispose the previous sandbox
// so the new one picks up the new tool/mount/allow-list set.
this._warmSnapshot?.Dispose();
this._sandbox?.Dispose();
this._warmSnapshot = null;
this._sandbox = null;
this.BuildAndWarmUp(snapshot);
}
private void BuildAndWarmUp(RunSnapshot snapshot)
{
var builder = new SandboxBuilder()
.WithBackend(this._options.Backend);
if (!string.IsNullOrEmpty(this._options.ModulePath))
{
builder = builder.WithModulePath(this._options.ModulePath!);
}
if (!string.IsNullOrEmpty(this._options.HeapSize))
{
builder = builder.WithHeapSize(this._options.HeapSize!);
}
if (!string.IsNullOrEmpty(this._options.StackSize))
{
builder = builder.WithStackSize(this._options.StackSize!);
}
var hostInput = snapshot.HostInputDirectory;
if (!string.IsNullOrEmpty(hostInput))
{
builder = builder.WithInputDir(hostInput!);
}
// The Hyperlight .NET SDK currently exposes only a single input + output + temp-output
// surface; per-mount configuration (`FileMount`) is captured in the execute_code
// description so the model is aware of the layout, and will be wired to a richer
// mount API once the SDK exposes one.
if (snapshot.FileMounts.Count > 0 || !string.IsNullOrEmpty(hostInput))
{
builder = builder.WithTempOutput();
}
var sandbox = builder.Build();
// Tools must be registered before the first Run() call.
ToolBridge.RegisterAll(sandbox, snapshot.Tools);
foreach (var allowedDomain in snapshot.AllowedDomains)
{
sandbox.AllowDomain(allowedDomain.Target, allowedDomain.Methods);
}
// Warm-up run to trigger lazy initialization, then capture a clean snapshot
// that is restored before every subsequent user invocation.
// Backend-specific no-op used to trigger lazy guest runtime initialization
// before the warm snapshot is captured. Matches the values used by the
// upstream HyperlightSandbox.Extensions.AI CodeExecutionTool reference.
_ = sandbox.Run(this._options.Backend == SandboxBackend.JavaScript ? "void 0;" : "None");
this._warmSnapshot = sandbox.Snapshot();
this._sandbox = sandbox;
this._lastConfigFingerprint = snapshot.ConfigFingerprint;
}
private static string BuildResult(ExecutionResult result) =>
JsonSerializer.Serialize(
new HyperlightExecutionResult(
result.Stdout ?? string.Empty,
result.Stderr ?? string.Empty,
result.ExitCode,
result.ExitCode == 0),
HyperlightJsonContext.Default.HyperlightExecutionResult);
private static string BuildErrorResult(string message) =>
JsonSerializer.Serialize(
new HyperlightExecutionResult(string.Empty, message, -1, false),
HyperlightJsonContext.Default.HyperlightExecutionResult);
public void Dispose()
{
if (this._disposed)
{
return;
}
this._disposed = true;
this._warmSnapshot?.Dispose();
this._sandbox?.Dispose();
this._executionLock.Dispose();
}
}
@@ -0,0 +1,94 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using HyperlightSandbox.Api;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight.Internal;
/// <summary>
/// Bridges an <see cref="AIFunction"/> to the
/// <see cref="Sandbox.RegisterToolAsync(string, Func{string, Task{string}})"/>
/// overload so the guest can invoke .NET tools via <c>call_tool(...)</c>.
/// </summary>
internal static class ToolBridge
{
/// <summary>
/// Registers every <paramref name="tools"/> entry against the provided
/// <paramref name="sandbox"/> as a raw JSON-in / JSON-out async tool.
/// </summary>
public static void RegisterAll(Sandbox sandbox, IReadOnlyList<AIFunction> tools)
{
foreach (var tool in tools)
{
RegisterOne(sandbox, tool);
}
}
private static void RegisterOne(Sandbox sandbox, AIFunction tool)
=> sandbox.RegisterToolAsync(
tool.Name,
async (string argsJson) => await InvokeAsync(tool, argsJson).ConfigureAwait(false));
internal static async Task<string> InvokeAsync(AIFunction tool, string argsJson)
{
try
{
var arguments = ParseArguments(argsJson);
var result = await tool.InvokeAsync(new AIFunctionArguments(arguments)).ConfigureAwait(false);
return SerializeResult(result);
}
#pragma warning disable CA1031 // Catch all: we must surface every failure as a JSON error to the guest rather than crash the FFI boundary.
catch (Exception ex)
#pragma warning restore CA1031
{
return JsonSerializer.Serialize(new HyperlightToolError(ex.Message), HyperlightJsonContext.Default.HyperlightToolError);
}
}
internal static IDictionary<string, object?> ParseArguments(string argsJson)
{
if (string.IsNullOrWhiteSpace(argsJson))
{
return new Dictionary<string, object?>(StringComparer.Ordinal);
}
// Use JsonNode.Parse instead of JsonSerializer.Deserialize<Dictionary<...>>
// so the bridge stays NativeAOT-compatible (the typed Deserialize overload
// requires reflection-based metadata for object-typed values).
var node = JsonNode.Parse(argsJson);
if (node is not JsonObject obj)
{
throw new ArgumentException(
"Tool arguments must be a JSON object.",
nameof(argsJson));
}
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var kvp in obj)
{
result[kvp.Key] = kvp.Value;
}
return result;
}
private static string SerializeResult(object? result)
{
if (result is null)
{
return "null";
}
// Tool results are arbitrary user types — defer to AIJsonUtilities so that
// the same trim/AOT-friendly serializer chain used elsewhere in the framework
// is applied here. The inputs are produced by user-supplied AIFunctions and
// therefore cannot be modeled in our own JsonSerializerContext.
var typeInfo = AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType());
return JsonSerializer.Serialize(result, typeInfo);
}
}
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<PackageReference Include="Hyperlight.HyperlightSandbox.Api" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework - Hyperlight CodeAct integration</Title>
<Description>Provides Hyperlight-backed CodeAct (sandboxed code execution) integration for Microsoft Agent Framework.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hyperlight.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,41 @@
# Microsoft.Agents.AI.Hyperlight
First-class [CodeAct](../../../docs/decisions/0024-codeact-integration.md)
support for the Microsoft Agent Framework, backed by the
[Hyperlight](https://github.com/hyperlight-dev/hyperlight) VM-isolated sandbox.
The package exposes two entry points:
* **`HyperlightCodeActProvider`** — an `AIContextProvider` that injects an
`execute_code` tool and CodeAct guidance into every agent invocation. Only
one `HyperlightCodeActProvider` may be attached to a given agent; it
enforces this through a fixed `StateKeys` value so `ChatClientAgent`'s
state-key uniqueness validation rejects duplicate registrations.
* **`HyperlightExecuteCodeFunction`** — a standalone `AIFunction` for
static/manual wiring when the sandbox configuration is fixed for the
agent's lifetime.
Both surfaces support:
* Provider-owned tools exposed inside the sandbox via `call_tool(...)`
(multiple allowed).
* Opt-in filesystem mounts and outbound network allow-list.
* `CodeActApprovalMode` control: `NeverRequire` (default; approval propagates
from tools wrapped in `ApprovalRequiredAIFunction`) and `AlwaysRequire`.
* Snapshot/restore per run so the guest starts from a known clean state
every invocation.
## Requirements
* The `Hyperlight.HyperlightSandbox.Api` NuGet package, published from the
`src/sdk/dotnet` SDK in [hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox)
(the .NET API was added in [PR #46](https://github.com/hyperlight-dev/hyperlight-sandbox/pull/46),
now merged). Until the package is published to nuget.org the project
restore will fail; this project is intentionally `IsPackable=false` in
the meantime.
* A Hyperlight Python guest module when using `SandboxBackend.Wasm`.
## Status
Preview. API may change until the underlying Hyperlight SDK reaches a stable
release.
@@ -56,6 +56,13 @@ public static class DeclarativeWorkflowBuilder
/// <param name="options">Configuration options for workflow execution.</param>
/// <param name="inputTransform">An optional function to transform the input message into a <see cref="ChatMessage"/>.</param>
/// <returns>The <see cref="Workflow"/> that corresponds with the YAML object model.</returns>
/// <remarks>
/// The returned workflow's root executor accepts <typeparamref name="TInput"/>,
/// <see cref="ChatMessage"/>, <see cref="System.Collections.Generic.IEnumerable{T}"/> of
/// <see cref="ChatMessage"/>, <see cref="string"/>, and <see cref="TurnToken"/>. This
/// makes the workflow usable both for direct invocation and for hosting via
/// <see cref="WorkflowHostingExtensions.AsAIAgent(Workflow, string?, string?, string?, IWorkflowExecutionEnvironment?, bool, bool)"/>.
/// </remarks>
public static Workflow Build<TInput>(
TextReader yamlReader,
DeclarativeWorkflowOptions options,
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
@@ -8,7 +9,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a request for external input.
/// </summary>
public sealed class ExternalInputRequest
public sealed class ExternalInputRequest : IExternalRequestEnvelope
{
/// <summary>
/// The source message that triggered the request for external input.
@@ -30,4 +31,47 @@ public sealed class ExternalInputRequest
{
this.AgentResponse = new AgentResponse(new ChatMessage(ChatRole.User, text));
}
/// <inheritdoc />
/// <remarks>
/// Prefers <see cref="ToolApprovalRequestContent"/> (when the workflow declared
/// <c>requireApproval: true</c>) over <see cref="FunctionCallContent"/> so that
/// hosts which speak the approval protocol see the approval-bearing content.
/// </remarks>
AIContent? IExternalRequestEnvelope.GetInnerRequestContent()
{
IList<ChatMessage>? messages = this.AgentResponse?.Messages;
if (messages is null)
{
return null;
}
foreach (ChatMessage message in messages)
{
foreach (AIContent content in message.Contents)
{
if (content is ToolApprovalRequestContent toolApprovalRequest)
{
return toolApprovalRequest;
}
}
}
foreach (ChatMessage message in messages)
{
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent functionCall)
{
return functionCall;
}
}
}
return null;
}
/// <inheritdoc />
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages)
=> new ExternalInputResponse(messages);
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
@@ -13,6 +14,24 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
/// <remarks>
/// In addition to the strongly-typed <typeparamref name="TInput"/> route inherited from
/// <see cref="Executor{TInput}"/>, this executor also accepts <see cref="string"/>,
/// <see cref="ChatMessage"/>, <see cref="IEnumerable{T}"/> of <see cref="ChatMessage"/>,
/// <see cref="ChatMessage"/><c>[]</c>, and <see cref="TurnToken"/> so that the workflow
/// satisfies <see cref="ChatProtocolExtensions.IsChatProtocol"/>. This makes the workflow
/// usable both for direct <c>Run.SendMessageAsync(input)</c> invocations and for hosting
/// via <see cref="WorkflowHostingExtensions.AsAIAgent(Workflow, string?, string?, string?, IWorkflowExecutionEnvironment?, bool, bool)"/>.
///
/// <para>
/// Each non-<see cref="TurnToken"/> input drives the declarative graph forward
/// immediately. The host's <see cref="TurnToken"/> arrives after the message batch and
/// is treated as a no-op because the inbound message has already been processed.
/// External responses (HITL function results) bypass the start executor entirely
/// (they are routed via <c>WorkflowSession.SendResponseAsync</c> to request-info
/// executors), so the start executor only ever sees a single inbound batch per turn.
/// </para>
/// </remarks>
internal sealed class DeclarativeWorkflowExecutor<TInput>(
string workflowId,
DeclarativeWorkflowOptions options,
@@ -26,29 +45,143 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
return default;
}
/// <inheritdoc/>
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
ChatMessage input = inputTransform.Invoke(message);
return this.AdvanceAsync(input, context, cancellationToken);
}
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
// Inherit the TInput route + method/class attributes (e.g. SendsMessage on HandleAsync).
ProtocolBuilder result = base.ConfigureProtocol(protocolBuilder);
// Add the chat-protocol input shapes so the workflow satisfies IsChatProtocol
// and can be hosted via AsAIAgent. Skip any shape that already matches TInput
// (the inherited route handles that case via inputTransform).
return result.ConfigureRoutes(this.ConfigureChatProtocolRoutes)
.SendsMessage<ActionExecutorResult>();
}
private void ConfigureChatProtocolRoutes(RouteBuilder routeBuilder)
{
Type tInput = typeof(TInput);
// Skip an exact-type match because RouteBuilder.AddHandler throws on duplicate
// registrations for the same message type. Equality (not IsAssignableFrom) is
// also what ChatProtocolExtensions.IsChatProtocol checks, so always registering
// IEnumerable<ChatMessage> when TInput is broader (e.g. object) keeps the
// workflow chat-protocol-compliant.
if (tInput != typeof(string))
{
routeBuilder.AddHandler<string>(this.HandleStringAsync);
}
if (tInput != typeof(ChatMessage))
{
routeBuilder.AddHandler<ChatMessage>(this.HandleChatMessageAsync);
}
if (tInput != typeof(IEnumerable<ChatMessage>))
{
routeBuilder.AddHandler<IEnumerable<ChatMessage>>(this.HandleChatMessagesAsync);
}
if (tInput != typeof(ChatMessage[]))
{
routeBuilder.AddHandler<ChatMessage[]>(this.HandleChatMessageArrayAsync);
}
if (tInput != typeof(TurnToken))
{
routeBuilder.AddHandler<TurnToken>(this.HandleTurnTokenAsync);
}
}
private ValueTask HandleStringAsync(string message, IWorkflowContext context, CancellationToken cancellationToken)
{
return this.AdvanceAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken);
}
private ValueTask HandleChatMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken)
{
return this.AdvanceAsync(message, context, cancellationToken);
}
private async ValueTask HandleChatMessagesAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
var list = messages as IList<ChatMessage> ?? new List<ChatMessage>(messages);
if (list.Count == 0)
{
return;
}
for (int i = 0; i < list.Count; i++)
{
await this.AdvanceAsync(list[i], context, cancellationToken, finalizeTurn: i == list.Count - 1).ConfigureAwait(false);
}
}
private async ValueTask HandleChatMessageArrayAsync(ChatMessage[] messages, IWorkflowContext context, CancellationToken cancellationToken)
{
if (messages.Length == 0)
{
return;
}
for (int i = 0; i < messages.Length; i++)
{
await this.AdvanceAsync(messages[i], context, cancellationToken, finalizeTurn: i == messages.Length - 1).ConfigureAwait(false);
}
}
// The host sends a TurnToken after the message batch; the message has already
// driven the graph forward, so we treat the token as a no-op here.
private ValueTask HandleTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
{
return default;
}
private async ValueTask AdvanceAsync(ChatMessage input, IWorkflowContext context, CancellationToken cancellationToken, bool finalizeTurn = true)
{
// No state to restore if we're starting from the beginning.
state.SetInitialized();
DeclarativeWorkflowContext declarativeContext = new(context, state);
ChatMessage input = inputTransform.Invoke(message);
string? conversationId = options.ConversationId;
// Conversation id resolution prefers state already persisted by a prior turn,
// so multi-turn invocations reuse the same backend conversation rather than
// creating a fresh one each turn.
string? conversationId = declarativeContext.GetWorkflowConversation();
if (string.IsNullOrWhiteSpace(conversationId))
{
conversationId = options.ConversationId;
}
bool conversationCreated = false;
if (string.IsNullOrWhiteSpace(conversationId))
{
conversationId = await options.AgentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false);
conversationCreated = true;
}
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
if (conversationCreated || !string.Equals(declarativeContext.GetWorkflowConversation(), conversationId, StringComparison.Ordinal))
{
await declarativeContext.QueueConversationUpdateAsync(conversationId!, isExternal: true, cancellationToken).ConfigureAwait(false);
}
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId!, input, cancellationToken).ConfigureAwait(false);
// Use the original input for System.LastMessage to ensure Text is preserved (the
// service may strip text on round-trip), but substitute server-side media references
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
if (finalizeTurn)
{
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -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
@@ -6,6 +6,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
@@ -19,6 +20,14 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
string activityText = this.Engine.Format(messageActivity.Text).Trim();
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
// Route through YieldOutputAsync so the activity participates in the workflow's
// output-filter pipeline. The runner currently special-cases AgentResponse to
// produce an AgentResponseEvent identical to the one we'd build by hand, so this
// is behavior-preserving today and forward-compatible if filtering is ever
// applied to agent responses.
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
return default;
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
namespace Microsoft.Agents.AI.Workflows;
@@ -10,6 +11,7 @@ namespace Microsoft.Agents.AI.Workflows;
[JsonDerivedType(typeof(ExecutorInvokedEvent))]
[JsonDerivedType(typeof(ExecutorCompletedEvent))]
[JsonDerivedType(typeof(ExecutorFailedEvent))]
[JsonDerivedType(typeof(MagenticOrchestratorEvent))]
public class ExecutorEvent(string executorId, object? data) : WorkflowEvent(data)
{
/// <summary>
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Optional interface implemented by request payload types that wrap underlying
/// AI content (such as <see cref="FunctionCallContent"/> or
/// <see cref="ToolApprovalRequestContent"/>) and define a paired response envelope.
/// </summary>
/// <remarks>
/// <para>
/// This abstraction allows higher-level layers (e.g., declarative workflows) to define
/// their own request/response envelope types while still allowing
/// <c>WorkflowSession</c> to surface the inner content to hosts on the request side
/// and to wrap incoming responses back into the envelope on the response side -
/// without the runtime taking a reference back to the higher-level layer.
/// </para>
/// <para>
/// When an <c>ExternalRequest.Data</c> payload implements this interface, the
/// runtime uses <see cref="GetInnerRequestContent"/> to drive wire serialization
/// for hosts (so a host receives a normal <see cref="FunctionCallContent"/> or
/// <see cref="ToolApprovalRequestContent"/>), and uses <see cref="CreateResponse"/>
/// to wrap the host's response payload back into the envelope expected by the
/// workflow's request port.
/// </para>
/// </remarks>
public interface IExternalRequestEnvelope
{
/// <summary>
/// Returns the inner AI content that should be delivered to the host on the wire.
/// Typically a <see cref="FunctionCallContent"/> or <see cref="ToolApprovalRequestContent"/>.
/// </summary>
/// <returns>The inner content, or <c>null</c> if no suitable inner content is available.</returns>
AIContent? GetInnerRequestContent();
/// <summary>
/// Wraps the supplied response messages into the envelope's matching response type
/// for delivery to the workflow's request port.
/// </summary>
/// <param name="messages">The response messages, typically containing a
/// <see cref="FunctionResultContent"/> and/or <see cref="ToolApprovalResponseContent"/>.</param>
/// <returns>An instance of the envelope's response type wrapping <paramref name="messages"/>.</returns>
object CreateResponse(IList<ChatMessage> messages);
}
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Request for human review of a proposed plan.
/// </summary>
/// <param name="Plan">The proposed plan.</param>
/// <param name="CurrentProgress">The current progress ledger, if available. During the initial plan review,
/// this will be <see langword="null"/>. In subsequent reviews after replanning (due to stalls), this will
/// contain the latest progress ledger that determined that no progress has been made or the workflow was in
/// a loop.</param>
/// <param name="IsStalled">Whether the workflow is currently stalled.</param>
public record MagenticPlanReviewRequest(ChatMessage Plan, MagenticProgressLedger? CurrentProgress, bool IsStalled)
{
/// <summary>
/// Create an approving <see cref="MagenticPlanReviewResponse"/>.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Approve() => new([]);
/// <summary>
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Revise(string message) => new([new(ChatRole.User, message)]);
/// <summary>
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Revise(ChatMessage message) => new([message]);
/// <summary>
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
/// </summary>
/// <returns></returns>
public MagenticPlanReviewResponse Revise(IEnumerable<ChatMessage> messages)
=> new(messages is List<ChatMessage> messageList ? messageList : messages.ToList());
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Review feedback for a proposed plan, including any revisions if the plan is not approved as-is. An
/// empty list of review messages indicates approval of the proposed plan without any revisions.
/// </summary>
/// <param name="Review">
/// Review feedback for a generated plan. Empty if the plan is approved as-is and changes are requested.
/// </param>
public record MagenticPlanReviewResponse(List<ChatMessage> Review)
{
internal bool IsApproved => this.Review.Count == 0;
}
@@ -0,0 +1,269 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Maintains a ledger of progress made by the Magentic workflow.
/// </summary>
public class MagenticProgressLedger
{
internal static readonly BooleanProgressLedgerSlot IsRequestSatisfiedSlot = new("is_request_satisfied",
"Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed)");
internal static readonly BooleanProgressLedgerSlot IsInLoopSlot = new("is_in_loop",
"Are we in a loop where we are repeating the same requests and or getting the same responses as before? " +
"Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times.");
internal static readonly BooleanProgressLedgerSlot IsProgressBeingMadeSlot = new("is_progress_being_made",
"Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent " +
"messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success " +
"such as the inability to read from a required file)");
internal readonly StringProgressLedgerSlot NextSpeakerSlot;
internal static readonly StringProgressLedgerSlot InstructionOrQuestionSlot = new("instruction_or_question",
"What instruction or question would you give this team member? (Phrase as if speaking directly to them, and " +
"include any specific information they may need)");
internal MagenticProgressLedger(string teamNames, IEnumerable<ProgressLedgerSlot> additionalQuestions, JsonElement? state = null)
{
this.NextSpeakerSlot = new("next_speaker", $"Who should speak next? (select from: {teamNames})");
this.AdditionalQuestions = additionalQuestions as ProgressLedgerSlot[] ?? additionalQuestions.ToArray();
if (state != null)
{
this.TryUpdateState(state.Value);
}
}
internal ProgressLedgerSlot[] AdditionalQuestions { get; }
internal bool TryUpdateState(JsonElement element)
{
// In principle all of these should be inlineable, but the CodeAnalysis fails to properly chain through the and-chain to realize that
// all must be true for `requiredQuestionsAnswered` to be true, meaning all of the out parameters would be initialized properly.
bool isInLoop = false;
bool isProgressBeingMade = false;
string? nextSpeaker = string.Empty;
string? instructionOrQuestion = string.Empty;
bool requiredQuestionsAnswered =
IsRequestSatisfiedSlot.TryGetValueFrom(element, out bool isRequestSatisfied) &&
IsInLoopSlot.TryGetValueFrom(element, out isInLoop) &&
IsProgressBeingMadeSlot.TryGetValueFrom(element, out isProgressBeingMade) &&
this.NextSpeakerSlot.TryGetValueFrom(element, out nextSpeaker) &&
InstructionOrQuestionSlot.TryGetValueFrom(element, out instructionOrQuestion);
if (requiredQuestionsAnswered)
{
this.State = element;
this.IsRequestSatisfied = isRequestSatisfied;
this.IsInLoop = isInLoop;
this.IsProgressBeingMade = isProgressBeingMade;
this.NextSpeaker = nextSpeaker!;
this.InstructionOrQuestion = instructionOrQuestion!;
}
// TODO: To what extent do we want to enforce that the additional questions are also answered?
return requiredQuestionsAnswered;
}
[JsonInclude]
internal JsonElement? State;
/// <summary>
/// Specifies whether plan execution has started.
/// </summary>
[JsonIgnore]
public bool IsStarted => this.State != null;
/// <summary>
/// Specifies whether the task has been fully satisfied.
/// </summary>
[JsonIgnore]
public bool IsRequestSatisfied { get; private set; }
/// <summary>
/// Specifies whether the team is in a loop.
/// </summary>
[JsonIgnore]
public bool IsInLoop { get; private set; }
/// <summary>
/// Specifies whether the team is making progress on the task.
/// </summary>
[JsonIgnore]
public bool IsProgressBeingMade { get; private set; }
/// <summary>
/// Gets the next team member to take a turn.
/// </summary>
[JsonIgnore]
public string NextSpeaker { get; private set; } = string.Empty;
/// <summary>
/// Gets the instruction or question to send to the next team member.
/// </summary>
[JsonIgnore]
public string InstructionOrQuestion { get; private set; } = string.Empty;
[JsonIgnore]
internal IEnumerable<ProgressLedgerSlot> Slots =>
[
IsRequestSatisfiedSlot,
IsInLoopSlot,
IsProgressBeingMadeSlot,
this.NextSpeakerSlot,
InstructionOrQuestionSlot,
.. this.AdditionalQuestions
];
internal bool TryGetCurrentSlotValue<T>(ProgressLedgerSlot<T> slot, [NotNullWhen(true)] out T? value)
{
if (!this.State.HasValue)
{
value = default;
return false;
}
return slot.TryGetValueFrom(this.State.Value, out value);
}
private (string QuestionBlock, string AnswerSchema)? _questionFormatCache;
internal (string QuestionBlock, string AnswerSchema) FormatQuestions()
{
if (!this._questionFormatCache.HasValue)
{
StringBuilder questionBuilder = new(), schemaBuilder = new();
schemaBuilder.AppendLine("{");
foreach (ProgressLedgerSlot slot in this.Slots)
{
questionBuilder.AppendLine(slot.FormattedQuestion);
schemaBuilder.AppendLine($"\"{slot.Key}\": {{")
.AppendLine($" \"{ProgressLedgerSlot.ValueKey}\": {slot.SchemaType}{slot.SuffixString},")
.AppendLine($" \"{ProgressLedgerSlot.ReasonKey}\": string")
.AppendLine("}");
}
schemaBuilder.AppendLine("}");
this._questionFormatCache = (questionBuilder.ToString(), schemaBuilder.ToString());
}
return this._questionFormatCache.Value;
}
}
internal abstract record ProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null)
{
public const string ValueKey = "answer";
public const string ReasonKey = "reason";
internal string SuffixString => this.SchemaTypeSuffix == null ? string.Empty : $"({this.SchemaTypeSuffix})";
protected internal abstract string SchemaType { get; }
public string FormattedQuestion
{
get
{
if (field == null)
{
IEnumerable<string> questionLines = this.Question.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.TrimEnd());
field = $" - {string.Join("\n ", questionLines)}";
}
return field;
}
}
}
internal abstract record ProgressLedgerSlot<T>(string Key, string Question, string? SchemaTypeSuffix = null, JsonSerializerOptions? SerializerOptions = null)
: ProgressLedgerSlot(Key, Question, SchemaTypeSuffix)
{
protected internal virtual JsonTypeInfo<T> GetJsonTypeInfo() =>
((this.SerializerOptions ?? WorkflowsJsonUtilities.DefaultOptions).TryGetTypeInfo(typeof(T), out JsonTypeInfo? typeInfo)
? typeInfo as JsonTypeInfo<T> : null)
?? throw new InvalidOperationException($"Cannot get TypeInfo for {typeof(T)} from {(this.SerializerOptions == null ? "provided" : "default")} SerializationOptions.");
public bool TryGetValueFrom(JsonElement answers, [NotNullWhen(true)] out T? value)
{
if (answers.TryGetProperty(this.Key, out JsonElement slotElement) &&
slotElement.ValueKind != JsonValueKind.Null &&
slotElement.TryGetProperty(ValueKey, out JsonElement answerValue))
{
try
{
T? result = answerValue.Deserialize(this.GetJsonTypeInfo());
if (result != null)
{
value = result;
return true;
}
}
catch
{
}
}
value = default;
return false;
}
public bool TryGetReasonFrom(JsonElement answers, [NotNullWhen(true)] out string? value)
{
if (answers.TryGetProperty(this.Key, out JsonElement slotElement) &&
slotElement.ValueKind != JsonValueKind.Null &&
slotElement.TryGetProperty(ReasonKey, out JsonElement reasonValue))
{
try
{
string? result = reasonValue.Deserialize(WorkflowsJsonUtilities.JsonContext.Default.String);
if (result != null)
{
value = result;
return true;
}
}
catch
{
}
}
value = default;
return false;
}
}
internal sealed record BooleanProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot<bool>(Key, Question, SchemaTypeSuffix)
{
// Since we know the type statically, we can directly return the JsonTypeInfo for string from our JsonContext,
// which is more efficient than looking it up via the options.
protected internal override JsonTypeInfo<bool> GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.Boolean;
protected internal override string SchemaType => "boolean";
}
internal sealed record StringProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot<string>(Key, Question, SchemaTypeSuffix)
{
// Since we know the type statically, we can directly return the JsonTypeInfo for string from our JsonContext,
// which is more efficient than looking it up via the options.
protected internal override JsonTypeInfo<string> GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.String;
protected internal override string SchemaType => "string";
}
@@ -0,0 +1,158 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorConfig<Microsoft.Agents.AI.Workflows.ExecutorOptions>,
string,
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Specialized.Magentic.MagenticOrchestrator>>;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Fluent builder for creating Magentic One multi-agent orchestration workflows.
///
/// Magentic One workflows use an LLM-powered manager to coordinate multiple agents through dynamic task planning, progress tracking,
/// and adaptive replanning.The manager creates plans, selects agents, monitors progress, and determines when to replan or complete.
///
/// The builder provides a fluent API for configuring participants, the manager, optional plan review, checkpointing, and event
/// callbacks.
///
/// Human-in-the-loop Support: Magentic provides specialized HITL mechanisms via:
/// - `RequirePlanSignoff` - Review and approve/revise plans before execution
/// - Tool approval via `function_approval_request`: Approve individual tool calls on participating agents. Note that tool calls are
/// not supported on the ManagerAgent.
/// </summary>
/// <param name="managerAgent"></param>
public class MagenticWorkflowBuilder(AIAgent managerAgent)
{
private readonly List<AIAgent> _team = new();
private string? _name;
private string? _description;
private int _maxStalls = TaskLimits.DefaultMaxStallCount;
private int? _maxRounds;
private int? _maxResets;
private bool _requirePlanSignoff = true;
/// <inheritdoc cref="GroupChatWorkflowBuilder.AddParticipants(IEnumerable{AIAgent})"/>
public MagenticWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agents)
{
this._team.AddRange(agents);
return this;
}
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
public MagenticWorkflowBuilder WithName(string name)
{
this._name = name;
return this;
}
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
public MagenticWorkflowBuilder WithDescription(string description)
{
this._description = description;
return this;
}
/// <summary>
/// Set the maximum number of coordination rounds. <see langword="null"/> means unlimited.
/// </summary>
/// <returns></returns>
public MagenticWorkflowBuilder WithMaxRounds(int? maxRounds = null)
{
this._maxRounds = maxRounds;
return this;
}
/// <summary>
/// Set the maximum number ofnumber of resets allowed. <see langword="null"/> means unlimited.
/// </summary>
/// <returns></returns>
public MagenticWorkflowBuilder WithMaxResets(int? maxResets = null)
{
this._maxResets = maxResets;
return this;
}
/// <summary>
/// Set the maximum number of consecutive rounds without progress before replan (default 3).
/// </summary>
/// <returns></returns>
public MagenticWorkflowBuilder WithMaxStalls(int maxStalls = TaskLimits.DefaultMaxStallCount)
{
this._maxStalls = maxStalls;
return this;
}
/// <summary>
/// If <see langword="true"/>, requires human approval of the initial plan or any updates before proceeding. True by default.
/// </summary>
/// <param name="requirePlanSignoff"></param>
/// <returns></returns>
public MagenticWorkflowBuilder RequirePlanSignoff(bool requirePlanSignoff = true)
{
this._requirePlanSignoff = requirePlanSignoff;
return this;
}
private WorkflowBuilder ReduceToWorkflowBuilder()
{
// Create a copy of the team so that improper modifications by using the builder after .Build() do not affect the
// workflow in unexpected ways.
List<AIAgent> team = [.. this._team];
ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff);
WorkflowBuilder result = new(orchestrator);
AIAgentHostOptions options = new()
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = false
};
List<ExecutorBinding> teamBindings = [];
foreach (AIAgent agent in team)
{
ExecutorBinding binding = agent.BindAsExecutor(options);
teamBindings.Add(binding);
result.AddEdge(binding, orchestrator);
}
result.AddFanOutEdge(orchestrator, teamBindings)
.WithOutputFrom(orchestrator);
if (!string.IsNullOrWhiteSpace(this._name))
{
result.WithName(this._name);
}
if (!string.IsNullOrWhiteSpace(this._description))
{
result.WithDescription(this._description);
}
return result;
}
/// <inheritdoc cref="WorkflowBuilder.Build"/>
public Workflow Build() => this.ReduceToWorkflowBuilder().Build();
private TaskLimits Limits => new(
MaxRoundCount: this._maxRounds,
MaxResetCount: this._maxResets,
MaxStallCount: this._maxStalls);
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
{
ExecutorFactoryFunc factory = CreateOrchestratorAsync;
return factory.BindExecutor(nameof(MagenticOrchestrator));
ValueTask<MagenticOrchestrator> CreateOrchestratorAsync(ExecutorConfig<ExecutorOptions> options, string sessionId)
{
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff));
}
}
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Notifies an AIAgent-hosting executor that it should reset its conversation state, and start a new session, if appropriate.
/// Note that for Agent Orchestrations, only Magentic makes use of this functionality.
/// </summary>
public sealed record ResetChatSignal();
@@ -24,7 +24,7 @@ internal static class TurnExtensions
=> handoffState.TurnToken.ShouldEmitStreamingEvents(agentSetting);
}
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
internal class AIAgentHostExecutor : ChatProtocolExecutor
{
private readonly AIAgent _agent;
private readonly AIAgentHostOptions _options;
@@ -40,7 +40,9 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
StringMessageChatRole = ChatRole.User
};
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: agent.GetDescriptiveId(),
public static string IdFor(AIAgent agent) => agent.GetDescriptiveId();
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: IdFor(agent),
s_defaultChatProtocolOptions,
declareCrossRunShareable: false) // Explicitly false, because we maintain turn state on the instance
{
@@ -67,7 +69,14 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder));
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ResetChatSignal>(this.ResetChat));
}
internal void ResetChat(ResetChatSignal signal, IWorkflowContext context)
{
this._session = null;
this._currentTurnEmitEvents = null;
}
private ValueTask HandleUserInputResponseAsync(
@@ -181,8 +190,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 +258,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; }
}
@@ -0,0 +1,175 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal static partial class ChatMessageExtensions
{
private static void ProcessAIContents(StringBuilder resultBuilder, IEnumerable<AIContent> contents, StreamingToolCallResultPairMatcher? pairMatcher = null)
{
pairMatcher ??= new();
foreach (AIContent content in contents)
{
switch (content)
{
case TextContent textContent:
resultBuilder.AppendLine(textContent.Text);
break;
//case DataContent dataContent:
// // We really do not know how to deal with anything other than image data with descriptions, which is not
// // a well-defined concept in MEAI (as contrasted with AutoGen's ImageContent type)
// break;
case ErrorContent errorContent:
resultBuilder.AppendLine($"[ERROR{(errorContent.ErrorCode != null ? $"(Code={errorContent.ErrorCode})" : string.Empty)}]");
resultBuilder.AppendLine(errorContent.Message);
if (errorContent.Details != null)
{
resultBuilder.Append("Details:").AppendLine(errorContent.Details);
}
break;
case FunctionCallContent functionCallContent:
pairMatcher.CollectFunctionCall(functionCallContent);
break;
case FunctionResultContent functionResultContent:
pairMatcher.TryResolveFunctionCall(functionResultContent, out string? functionName);
string result = functionResultContent.Result?.ToString() ?? string.Empty;
resultBuilder.AppendLine($"[Tool Call '{functionName ?? functionResultContent.CallId}' Result]")
.AppendLine(result);
break;
case McpServerToolCallContent mstContent:
pairMatcher.CollectMcpServerToolCall(mstContent);
break;
case McpServerToolResultContent mstResultContent:
if (mstResultContent.Outputs?.Any() is true)
{
pairMatcher.TryResolveMcpServerToolCall(mstResultContent, out string? mcpServerToolName);
resultBuilder.AppendLine($"[Start MCP Server Tool Call '{mcpServerToolName ?? mstResultContent.CallId}' Results]");
ProcessAIContents(resultBuilder, mstResultContent.Outputs!);
resultBuilder.AppendLine($"[End MCP Server Tool Call '{mcpServerToolName ?? mstResultContent.CallId}']");
}
break;
case TextReasoningContent reasoningContent:
if (!string.IsNullOrWhiteSpace(reasoningContent.Text))
{
resultBuilder.Append("[Reasoning] ")
.AppendLine(reasoningContent.Text);
}
break;
case UriContent uriContent:
resultBuilder.AppendLine(uriContent.Uri.ToString());
break;
}
}
}
public static string GetText(this List<ChatMessage> messages)
{
if (messages.Count == 0)
{
return string.Empty;
}
StringBuilder builder = new();
StreamingToolCallResultPairMatcher pairMatcher = new();
foreach (ChatMessage message in messages)
{
ProcessAIContents(builder, message.Contents, pairMatcher);
}
return builder.ToString();
}
private const string FencedJsonRegexPattern = @"```(?<lang>[a-z]+)?\s*(?<json>\{[\s\S]*?\})\s*```";
#if NET
[GeneratedRegex(FencedJsonRegexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)]
public static partial Regex FencedJsonRegex();
#else
public static Regex FencedJsonRegex() => s_fencedJsonRegex;
private static readonly Regex s_fencedJsonRegex =
new(FencedJsonRegexPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture);
#endif
internal static JsonElement ExtractJson(string messageText)
{
Match match = FencedJsonRegex().Match(messageText);
if (match.Success)
{
return JsonElement.Parse(match.Groups["json"].Value);
}
int start = messageText.IndexOf('{'), scanHead = start;
int? end = null;
if (scanHead < 0)
{
throw new InvalidOperationException("No JSON object found.");
}
int depth = 0;
bool inQuotes = false, inEscape = false;
for (; scanHead < messageText.Length && end is null; scanHead++)
{
if (inEscape)
{
inEscape = false;
continue;
}
switch (messageText[scanHead])
{
case '{' when !inQuotes:
depth++;
break;
case '}' when !inQuotes:
depth--;
if (depth == 0)
{
end = scanHead;
}
break;
case '\"':
// We already handled inEscape, so we can always flip inQuotes here
inQuotes = !inQuotes;
break;
case '\\':
Debug.Assert(!inEscape);
inEscape = true;
break;
}
}
if (end is null)
{
throw new InvalidOperationException("Unbalanced JSON braces.");
}
return JsonElement.Parse(messageText.Substring(start, end.Value - start + 1));
}
public static JsonElement ExtractJson(this ChatMessage message) => ExtractJson(message.Text);
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal sealed class ExecutorAgentHarness(AIAgent agent, AIAgentUnservicedRequestsCollector collector)
{
internal const string AgentSessionKey = nameof(AgentSession);
private AgentSession? _session;
private async ValueTask<AgentSession> EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
this._session ??= await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
public async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default)
{
AgentResponse response;
if (emitUpdateEvents)
{
// Run the agent in streaming mode only when agent run update events are to be emitted.
IAsyncEnumerable<AgentResponseUpdate> agentStream = agent.RunStreamingAsync(
messages,
await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken);
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update);
updates.Add(update);
}
response = updates.ToAgentResponse();
}
else
{
// Otherwise, run the agent in non-streaming mode.
response = await agent.RunAsync(messages,
await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
collector.ProcessAgentResponse(response);
}
return response;
}
public async ValueTask<JsonElement?> SerializeSessionAsync(CancellationToken cancellationToken)
=> this._session == null
? null
: await agent.SerializeSessionAsync(this._session, cancellationToken: cancellationToken).ConfigureAwait(false);
public async ValueTask DeserializeSessionAsync(JsonElement? serializedSession, CancellationToken cancellationToken)
{
this._session = serializedSession == null
? null
: await agent.DeserializeSessionAsync(serializedSession.Value, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
public void ResetSession()
{
this._session = null;
}
}
@@ -0,0 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal static class MagenticConstants
{
public const string MagenticTaskContextKey = nameof(MagenticTaskContextKey);
}

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