Compare commits

..
Author SHA1 Message Date
Peter Ibekwe 72a193086e Fix Foreach body exit wiring in declarative workflows 2026-05-22 15:19:36 -07:00
Peter IbekweandGitHub 793403f3db .NET: Add MCP long-running task support for MCP client tools (#5994)
* Add MCP long-running task support for MCP client tools

* Fixed project file formatting issue.

* Removed experimentation tag from MCP alpha project.

* Addressed PR comments
2026-05-22 19:09:54 +00:00
CopilotGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Jacob Alber
9fdd7429a8 .NET: Add Magentic Orchestration Sample (#5823)
* Add Magentic orchestration sample scaffold

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/8799740a-74d8-4100-b6f6-76dcd0418c87

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Validate Magentic orchestration sample

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/8799740a-74d8-4100-b6f6-76dcd0418c87

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Document follow-up changes for the Magentic .NET sample

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/caa3488f-d6f5-494d-a928-a45d6a98b3c3

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Remove CHANGES.md from Magentic sample

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/ffab38e2-37f9-4643-a782-20680573965a

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Fix PauseIfInteractive to also skip when stdout is redirected

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/07ddf735-29cc-4775-b588-fd71ca76fa58

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* fix: Update for PR Review Feedback

* fix: Update Sample README for PR Feedback

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-22 19:09:18 +00:00
abc9b60ec9 fix: populate MessageId from TaskStatusUpdateEvent.Status.Message (#6043)
When A2AAgent receives a TaskStatusUpdateEvent during streaming,
ConvertToAgentResponseUpdate now sets AgentResponseUpdate.MessageId
from Status.Message.MessageId when the message is present.

This fixes the missing message correlation metadata reported in
microsoft/agent-framework#4987.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-22 18:15:06 +00:00
Yufeng HeandGitHub 6bc0dc5911 fix: update sequential workflow sample output handling (#5976) 2026-05-22 15:31:18 +00:00
Yufeng HeandGitHub cf91819625 Python: fix Foundry handoff argument serialization (#5861) 2026-05-22 15:30:55 +00:00
578416a379 Python: fix(core): point @experimental warnings at user code, not stdlib internals (#5996)
* fix(core): point @experimental warnings at user code, not stdlib internals

Previously the wrappers installed by @experimental called warnings.warn
with a fixed stacklevel=3. ABCMeta inserts an extra abc.__new__ frame
when an experimental ABC is subclassed, so the warning landed inside
abc.py (or <frozen abc>:106 on modern CPython) instead of the user's
class Sub(...) line.

Resolve the user frame by walking inspect.currentframe(), skipping
frames whose module name is abc/functools/typing/contextlib (or
submodules), then emit via warnings.warn_explicit so the recorded
filename/lineno point at user code. Falls back to warnings.warn with
stacklevel=2 if no user frame is found. Module-name matching is used
because frozen stdlib modules report '<frozen abc>' as their filename.

Also install a one-line warnings.formatwarning specifically for
FeatureStageWarning so 'file:line: ExperimentalWarning: [ID] Name ...'
prints without the secondary source-snippet line. Other categories
delegate to the stdlib default formatter unchanged.

Added a regression test that subclasses an @experimental ABC inside
warnings.catch_warnings and asserts the recorded filename equals the
test file.

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

* fix(core): address review feedback on @experimental warning fix

- Make _install_feature_stage_formatter idempotent: tag the installed
  formatter with a marker attribute and short-circuit re-installation,
  so re-imports/reloads don't wrap the formatter on top of itself.
  Also expose the previous formatter via __wrapped__ for restoration.
- Avoid leaking frame references in _resolve_user_frame: capture data
  into plain locals inside try and del frame/candidate in finally,
  per CPython's guidance on inspect.currentframe usage.
- Drop redundant _WARNED_FEATURES.clear() in the new ABC subclass test
  (the autouse fixture already handles it).

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

* changed query for foundry web search test

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-22 12:07:10 +00:00
Evan MattsonandGitHub c82c0133fc Workflow improvement (#6025) 2026-05-22 15:56:32 +09:00
950673ba47 Python: bump package versions for 1.6.0 release (#6017)
* Python: bump package versions for 1.6.0 release

- Released cohort (agent-framework, core, openai, foundry): 1.5.0 -> 1.6.0
- Beta packages (21 packages): 1.0.0b260519 -> 1.0.0b260521
- Alpha packages (azure-contentunderstanding, foundry-hosting, gemini, monty): 1.0.0a260518/19 -> 1.0.0a260521
- ag-ui stays at 1.0.0rc2, orchestrations at 1.0.0rc1 (dependency bounds updated)
- Inter-package dependency lower bounds updated (>=1.5.0,<2 -> >=1.6.0,<2)
- Update CHANGELOG compare links
- uv.lock refreshed

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

* Address review: bump RC packages, add shell tool to changelog

- ag-ui: 1.0.0rc2 -> 1.0.0rc3
- orchestrations: 1.0.0rc1 -> 1.0.0rc2
- Add shell tool (#5664) to CHANGELOG
- uv.lock refreshed

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-22 01:59:20 +00:00
5ac864dfd9 Updating versions for release 1.6.2 (#6019)
Co-authored-by: alliscode <bentho@microsoft.com>
2026-05-22 01:10:34 +00:00
b559545fa4 .NET: Fix declarative workflow regressions for hosted agents (#5905)
* Fix declarative workflow regressions for hosted agents

Three regressions surfaced when running a declarative workflow as a
Foundry hosted agent. Together they caused every condition group to fall
through to elseActions and the raw agent JSON to leak to the caller.

1. AgentProviderExtensions.InvokeAgentAsync forced autoSend to true
   whenever the agent ran on the workflow conversation, which overrode
   the explicit autoSend: false declared in workflow.yaml and streamed
   the raw structured-output JSON straight to the user. Honor the
   caller-supplied autoSend instead.

2. IWorkflowContextExtensions.ReadState / QueueStateUpdateAsync /
   QueueStateResetAsync took the variable name and namespace alias
   directly from PropertyPath.VariableName / NamespaceAlias. Against
   Microsoft.Agents.ObjectModel 2026.2.4.1 those properties return null
   for a dotted reference such as `Local.Triage` even when
   SegmentCount == 2 and IsValid == true, so every assignment threw
   ArgumentNullException via Throw.IfNull. Fall back to Segments() to
   reconstruct the name and alias when the parser returns null.

3. The same ObjectModel version no longer recognizes the user-facing
   `Local` scope alias: VariableScopeNames.IsValidName(`Local`)
   returns false and GetNamespaceFromName(`Local`) returns Unknown, so
   the declarative interpreter's IsManagedScope check fails and the
   State.Set call is silently skipped. Translate the `Local` alias to
   its canonical `Topic` form before forwarding to
   QueueStateUpdateAsync; WorkflowFormulaState.Bind continues to expose
   it as `Local` to PowerFx.

Verified end-to-end against a deployed Foundry hosted agent: the
declarative triage workflow now routes Technical / Billing / General
inputs correctly and only the autoSend-eligible messages reach the
caller.

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

* Hosted-agent HITL: persist session across previous_response_id chains; run approved local AIFunctions

Two regressions hit declarative workflows that use require_approval=true when
the client chains turns via previous_response_id (no conversation_id):

1. AgentFrameworkResponseHandler keyed the AgentSession store solely on
   conversation_id, so when only previous_response_id was present the
   StateBag (which holds ToolApprovalIdMap) was discarded after each turn.
   The next turn then threw 'No approval mapping recorded for wire id ...'
   in InputConverter.ConvertMcpApprovalResponse.

   Fix: fall back to previous_response_id on load and to context.ResponseId
   on save so the response-id chain becomes a valid session key. Conversation
   id remains preferred when present.

2. InvokeFunctionToolExecutor.CaptureResponseAsync only acted on
   FunctionResultContent. In the hosted Foundry path the approval response
   arrives as a ToolApprovalResponseContent with no FunctionResultContent,
   so the local AIFunction never ran and downstream PropertyPath/SendActivity
   consumers (e.g. {Local.RefundResult}) saw empty values.

   Fix: when no FunctionResultContent matches but an approved
   ToolApprovalResponseContent does, look up the registered AIFunction by
   name on agentProvider.Functions and invoke it with the evaluated
   arguments, surfacing the result through the existing assignment path.

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

* Apply PropertyPath workaround to initialization path; share + tidy helpers

Address PR #5905 review feedback:

* Move the PropertyPath VariableName/NamespaceAlias fallback and 'Local'
  -> 'Topic' scope remap into a shared internal PropertyPathExtensions
  helper. Materializes Segments() once, names the magic 'Local' alias
  as a const, and carries a TODO referencing the tracking issue.

* Apply the same helper in WorkflowDiagnostics.InitializeDefaults so a
  declared default for a dotted variable like 'Local.Triage' is no
  longer silently skipped at workflow startup (closes the gap flagged
  by the reviewer: runtime ReadState/QueueStateUpdateAsync worked but
  state.Initialize did not).

* Restore the previous strict failure mode on namespace alias by
  wrapping GetNamespaceAlias() in Throw.IfNull at call sites so a
  malformed single-segment path keeps failing fast rather than
  silently passing null to State.Get/Set.

All 821 unit tests pass.

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

* Add tests for AgentProviderExtensions.InvokeAgentAsync autoSend behavior

Covers the autoSend regression fix: when the agent runs on the workflow conversation with autoSend=false, no AgentResponseUpdateEvent or AgentResponseEvent is added to the context. Also covers autoSend=true (events emitted) and autoSend=false on a non-workflow conversation.

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

* Surface SendActivity output via AgentResponseUpdateEvent

SendActivityExecutor previously only emitted the activity text via YieldOutputAsync, which the runtime converts to an AgentResponseEvent. WorkflowSession gates AgentResponseEvent behind includeWorkflowOutputsInResponse, so when a host opts out of summary outputs (the default for AsAIAgent) the SendActivity reply is silently dropped.

Mirror the pattern used by AgentProviderExtensions for autoSend agent invocations: also emit an AgentResponseUpdateEvent, which WorkflowSession yields unconditionally. This makes SendActivity reliably reach chat-protocol clients without requiring includeWorkflowOutputsInResponse = true (which would also duplicate autoSend agent output).

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

* Revert previous_response_id session-key fallback

The fallback let a session be keyed by an unbroken previous_response_id chain,
but conversation_id is the right way to thread state across turns: it survives
shared/branched chains (e.g. when another agent generates a response in between)
and is the documented model for stateful clients. Restore conversation_id as the
sole session key and rely on the client to thread it. The InvokeFunctionTool
approval/local-function half of 1baf4af4d remains.

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

* Set Foundry ProductContext per-executor instead of via PropertyPath workaround

ObjectModel 2026.2.4.1 resolves PropertyPath.VariableName / NamespaceAlias and VariableScopeNames.IsValidName against AsyncLocal<ProductContext> at access time. In hosted-agent scenarios each HTTP request runs on a fresh async context where that AsyncLocal is default, so dotted refs like Local.Triage returned null and the Local scope alias was rejected.

Replace the PropertyPathExtensions helper (which papered over both symptoms) with a single WorkflowDiagnostics.SetFoundryProduct() call at the entry of DeclarativeActionExecutor.HandleAsync. The set writes to the request's logical async context before any code reads PropertyPath, letting the existing parser and scope resolver work as designed.

Validated: 824/824 declarative unit tests pass; technical/billing/general routes all dispatch correctly against a deployed Foundry hosted agent.

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

* Address review feedback on InvokeFunctionToolExecutor

- Surface registered-function lookup failures and invocation exceptions via FunctionResultContent.Exception instead of returning the error text as a successful Result, so downstream {Local.X} assignments can distinguish failures from successes.

- Use AIJsonUtilities.DefaultOptions to JSON-serialize non-string function results (matching FunctionInvokingChatClient / ToolBridge), so complex types stay consumable by PropertyPath consumers instead of degrading to Object.ToString().

- Drop the explicit System. prefix on StringComparison / Exception now that the file imports System.

- Add AutoSendTrueOnExternalConversationEmitsResponseEventsAndCopiesMessagesAsync to cover the (autoSend: true, external conversation) quadrant, asserting that response events are emitted and that messages are mirrored to the workflow conversation.

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

* Honor AutoSendIsDefaultValue when computing autoSend

AzureAgentOutput.AutoSend and InvokeToolOutput.AutoSend in
Microsoft.Agents.ObjectModel 2026.2.4.1 are never null — they
return a literal-false default when the YAML omits the field.
The previous null check in Get/AutoSendValue therefore always
fell through to evaluating the literal false, so every action
whose YAML had any output block but no explicit autoSend was
treated as autoSend = false. This was previously masked by
`autoSend |= isWorkflowConversation` in AgentProviderExtensions
(removed earlier in this PR to honor explicit autoSend: false),
which silently re-enabled autoSend on the workflow conversation.

Use AutoSendIsDefaultValue to distinguish an explicit autoSend
value from the implicit default and treat the implicit default
as true, restoring the historical behavior for ValidateCaseAsync
InvokeAgent.yaml (3 InvokeAzureAgent actions, last one captures
to Local.RatingResponse via output.messages with no autoSend
specified) while keeping the hosted-agent fix that honors an
explicit autoSend: false.

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

---------

Co-authored-by: Ben Thomas <25218250+alliscode@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-22 01:06:38 +00:00
8e54f0b0e7 Python: Shell tool with support for local and Docker (#5664)
* feat(tools): add cross-OS LocalShellTool in new agent-framework-tools package

Introduces a safe, cross-OS local shell tool as the first citizen of a new

agent-framework-tools workspace package. Supports persistent (default) and

stateless modes across pwsh/powershell.exe/bash/sh, with policy denylist,

allowlist, approval gating, process-tree kill on timeout, output truncation,

and audit hooks. Integrates with existing provider get_shell_tool(func=...)

factories via FunctionTool kind='shell'.

See docs/decisions/0026-builtin-tools-local-shell.md for the full design.

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

* feat(tools): security hardening for LocalShellTool

Codifies what LocalShellTool does and does not defend against, and

delegates the security-relevant lifecycle primitive to a battle-tested

library instead of hand-rolled per-OS code.

Changes:

- Adopt psutil for cross-OS process-tree termination (executor + session).

  Replaces hand-rolled taskkill/killpg with one canonical implementation.

- Resolve taskkill.exe to absolute %SystemRoot%\System32 path so PATH

  poisoning cannot redirect us to an attacker-supplied binary.

- Reframe ShellPolicy docstring + ADR + README: denylist is a guardrail,

  not a security boundary.

- Require acknowledge_unsafe=True to set approval_mode='never_require',

  making the unsafe path explicitly opt-in with a self-documenting name.

- Add tests/test_security.py codifying named CVE-style cases. Defenses

  we DO claim are asserted; non-defenses (denylist bypasses via

  backslash insertion, variable expansion, interpreter escape, base64,

  alternative tools, PowerShell-native verbs) are documented as

  expected-to-pass tests so residual risk stays visible.

- Add Threat Model + Confidence Strategy sections to ADR 0026.

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

* feat(tools): add DockerShellTool sandboxed shell tier

Adds a container-backed shell executor as the recommended pattern for untrusted-input shell workflows. The container provides the security boundary (--network none, non-root user, --read-only, --cap-drop ALL, no-new-privileges, memory/pids limits, tmpfs /tmp), so approval gating is optional unlike LocalShellTool.

Also introduces a ShellExecutor Protocol so callers can plug in custom backends (Firecracker, SSH, WASI) without forking the framework.

Removes the planned HyperlightShellExecutor follow-up from ADR 0026: Hyperlight is a WASM code sandbox with no kernel/userland/shell binary, so a Hyperlight-backed shell is not viable. Docker is the realistic sandbox tier for shell.

Tests: 11 unit tests for argv builders + lifecycle (no Docker daemon required); 3 integration tests gated on is_docker_available().

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

* fix(tools): backport shell-tool fixes from .NET parity review

Applies the applicable subset of bug fixes accumulated during the
.NET shell-tool PR review (microsoft/agent-framework#5604) to the
Python shell tool.

A1 - Quote workdir safely in _maybe_reanchor

  Previously _tool.py used double-quote interpolation when emitting
  the cd/Set-Location prefix, which expanded $VAR, $(), and backticks
  in the workdir path. A workdir containing shell metacharacters could
  trigger arbitrary command execution before the user command ran.

  Replaced with single-quote escaping helpers _quote_posix and
  _quote_powershell that emit literal-string forms safe for both
  hosts.

A5/A6 - Consolidate truncation to a single byte-aware helper

  Extracted a shared truncate_head_tail / truncate_text_head_tail
  helper in _truncate.py. The new implementation distributes odd
  caps so head receives floor(cap/2) and tail receives ceil(cap/2)
  bytes, matching the .NET round-9 fix and ensuring no input bytes
  are silently dropped on the boundary.

  _session.py previously truncated by Python str length while the
  caller passed _max_output_bytes - the unit mismatch is now gone:
  raw byte buffers go through truncate_head_tail and decoded text
  goes through truncate_text_head_tail.

Unit tests added for the truncate and quote helpers.

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

* docs(tools): tone down narrative and overconfident comments in shell tool

The shell tool's docstrings and comments contained two patterns that
the .NET review pushed back on:

- Narrative framing about implementation history ("hard-won",
  "we sidestep", "design inspiration: ...", competitor framework
  name-drops in module docstrings).
- Overstated security guarantees ("battle-tested",
  "reasonable for untrusted input", "recommended executor for any
  agent that runs commands from untrusted input",
  "destructive commands are blocked", "safe local shell tool",
  "blocks shell injection").

Rewrites the affected docstrings and comments to describe what the
code does in neutral terms. Behaviour is unchanged.

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

* feat(tools): add ShellEnvironmentProvider for the Python shell tool

Ports the .NET ShellEnvironmentProvider as a Python ContextProvider
so agents using LocalShellTool or DockerShellTool can be primed with
an accurate description of the shell they're talking to (family,
version, OS, working directory, and which CLIs are available).

The provider runs probes through any ShellExecutor, caches the
resulting snapshot, and on every before_run extends the session
instructions with a markdown block describing the shell idiom to
use. A failed first probe leaves the cache empty so the next call
retries (no permanent poisoning).

Probe failures from a narrow set of expected error types
(ShellCommandError, ShellExecutionError, ShellTimeoutError, and
asyncio.TimeoutError from the per-probe timeout) are recorded as
None fields in the snapshot. Other exceptions propagate. Tool
names are validated against ^[A-Za-z0-9._-]+$ before being
interpolated into a probe command.

Includes 12 unit tests covering happy path, stderr fallback,
timeout handling, expected/unexpected exception paths, malicious
tool name rejection, case-insensitive deduplication, retry after
failure, concurrent first-callers sharing one probe, and the
default and custom formatter paths.

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

* docs(tools): document ShellEnvironmentProvider and finish comment cleanup

Add a README section introducing ShellEnvironmentProvider, soften two remaining overconfident security-boundary comments in _executor_base.py and the DockerShellTool class docstring, and add a sample (shell_with_environment_provider.py) that demonstrates the provider in stateless and persistent modes.

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

* refactor(tools): move shell samples to python/samples/02-agents/tools

The repository convention is to host samples under python/samples/ rather than inside the package directory. Move the two net-new shell samples (allow-list and environment-provider) to python/samples/02-agents/tools/ and drop the in-package samples/ directory; the existing top-level providers/openai/client_with_local_shell.py already covers the basic LocalShellTool walkthrough.

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

* test(tools): cover confine_workdir default and ShellResult.format_for_model

Two new tests in test_local_shell_tool.py exercise the default confine_workdir=True behaviour on POSIX and PowerShell, asserting that 'cd' inside one persistent-mode call does not leak into the next. A new test_shell_result.py module provides direct unit coverage for every conditional branch of ShellResult.format_for_model (stdout, truncated, stderr, timed_out, exit_code) so regressions in the LLM-facing format are caught immediately.

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

* fix(tools): address PR #5664 review feedback

- _tool.py: detect PowerShell via is_powershell() helper instead of basename string match

- _environment.py: use public ContextProvider import (no private _ prefix)

- _session.py: trim _stdout_buf/_stderr_buf after copying to avoid unbounded retention across calls

- _docker.py: short-circuit start()/close() in stateless mode; add configurable shell kwarg (default bash, e.g. 'sh' for alpine)

- tests: parenthesized multi-line assert; alpine integration tests now pass shell='sh'

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

* fix(tools): satisfy CI quality gates

- pyupgrade: drop quoted self-class refs in __aenter__/method annotations

- ruff format: reflow long lines per workspace style

- pyright: assert psutil non-None in optional-import branch; lowercase mutable module globals; annotate _approval_mode as Literal so tool() Literal-typed kwarg is accepted; add ... body to ShellExecutor.run protocol; remove unused deprecated _kill_tree wrapper

- tests: skip docker integration tests on win32 (Windows containers don't support --read-only / alpine images)

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

* Remove DEFAULT_DENYLIST; document single-session ownership; fix bandit findings

Mirrors the .NET PR #5604 cleanup:

- Remove DEFAULT_DENYLIST from ShellPolicy. ShellPolicy() now ships with an empty deny-list; operators opt into site-specific patterns explicitly. No major agent framework uses regex matching as a primary security control; AutoGen v2 removed theirs. Approval gating + sandbox tier remain the real boundaries.

- Rewrite module / class docstrings to frame ShellPolicy as a UX pre-filter, not a security control.

- Add Single-session ownership paragraphs to ShellExecutor, ShellSession, LocalShellTool, and DockerShellTool: a persistent-mode tool is owned by exactly one conversation / agent session; do not share across users or concurrent conversations.

- Tests now supply explicit deny patterns instead of relying on a default.

- Address Pre-commit Hooks (bandit) CI failures: convert internal-invariant asserts to explicit RuntimeError, annotate intentional subprocess/shell usage with # nosec, document container-internal /tmp paths.

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

* Address PR #5664 round-2 review feedback

Deny-list documentation drift:

- README and the OpenAI/local-shell sample no longer claim a built-in deny-list of destructive commands. ShellPolicy is described as an optional, operator-supplied UX pre-filter; the real boundaries remain approval gating and the sandbox tier.

Behavioural fixes called out in review:

- ShellPolicy.evaluate() now denies empty / whitespace-only commands explicitly instead of returning allow with no rationale.

- truncate_head_tail() raises ValueError for cap <= 0 instead of silently returning the full input with truncated=False, which previously could defeat output-capping in callers that mis-configured the budget.

- LocalShellTool.as_function() / DockerShellTool.as_function() return the ShellCommandError text directly so the model sees a single, non-redundant 'Command rejected by policy: …' message instead of the prior duplicated 'Command blocked by policy: Command rejected …' wrapping.

- ShellSession POSIX sentinel trailer now snapshots and restores the prior errexit (set -e) state around the trailer, so a user 'set -e' in the persistent shell is no longer permanently disabled by the next run().

Tests:

- New test_shell_parse_rc.py covers the full _parse_rc() edge-case surface (zero, positive, negative, CRLF, no newline, missing prefix, empty input, non-digits, trailing garbage, partial digits).

- test_policy.py asserts the new empty-command deny.

- test_shell_truncate_and_quote.py asserts ValueError for cap=0 and cap<0.

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

* Address PR review feedback for shell tool

- _resolve.py: reject empty/whitespace shell override string
- _tool.py / _docker.py: mode-aware default tool description (persistent vs stateless)
- _tool.py: fix misleading workdir docstring (re-anchor, not blocking)
- _types.py: emit stream-agnostic [output truncated] marker
- _policy.py: declare _denies/_allows as dataclass fields
- _environment.py: use $(pwd) instead of $PWD in POSIX probe

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

* Address PR review feedback: shell override flag + probe timeout safety

- _resolve.py: in stateless mode, ensure shell overrides end with -c/-Command so commands aren't misinterpreted as script-file paths.
- ShellExecutor.run / LocalShellTool.run / DockerShellTool.run now accept an optional 	imeout kwarg; ShellEnvironmentProvider drops the outer asyncio.wait_for and lets the executor enforce the probe timeout internally, so cancellation no longer risks leaving a hung subprocess or corrupted session.

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

* Address review feedback: docker isolation + lifecycle robustness

- pyproject.toml: bump agent-framework-core minimum from 1.2.0 to 1.2.2 to align with the rest of the workspace.
- _docker.py: validate extra_run_args at construction time and reject flags that would dismantle the isolation defaults (--privileged, --cap-add, --security-opt, --network/--net, -v/--volume/--mount, --device, --pid, --ipc, --userns, --user, --read-only, --tmpfs, --add-host, --gpus, --cgroupns, --device-cgroup-rule); also documented the warning on the docstring.
- _docker._stop_container: retry docker rm -f once and log a warning/error when it does not succeed, so operators can audit leaked containers instead of getting a silent success.
- _docker._run_stateless timeout path: fall back to docker rm -f when docker kill fails or times out (--rm only reaps on clean exit), and log instead of silently swallowing communicate() errors.

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

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: alliscode <25218250+alliscode@users.noreply.github.com>
2026-05-22 00:29:59 +00:00
afd2739e38 .NET: Surface x-ms-served-model header as ChatResponse.ModelId for Foundry agents (#5979)
* .NET: Surface x-ms-served-model header as ChatResponse.ModelId for Foundry agents

Mirrors Python PR #5910. Adds an internal SCM PipelinePolicy that reads the x-ms-served-model HTTP response header on Azure OpenAI Responses calls and writes it into an AsyncLocal box. A DelegatingChatClient sits between OpenTelemetry and the MEAI OpenAIResponsesChatClient and overwrites ChatResponse.ModelId with the served snapshot so OTel spans report the actual model rather than the deployment alias. Wired through all AsAIAgent paths in Microsoft.Agents.AI.Foundry.

* .NET: Fix line endings and BOM on ResponsesAgentServedModelTests

* .NET: Address Copilot review on Foundry served-model PR

- Restore previous ServedModelScope in finally to avoid AsyncLocal leak into caller execution context.
- Make served-model integration test assertion robust to deployment names that already match the snapshot pattern.
- Broaden UnitTests csproj comment to cover all conditional removals (net8.0+ requirement).

* .NET: Split ServedModelTests into per-SUT files with regions

Split the combined ServedModelTests.cs into one test class per SUT:

- ServedModelScopeTests.cs (AsyncLocal carrier)
- ServedModelPolicyTests.cs (SCM pipeline policy)
- ServedModelChatClientTests.cs (delegating client, with regions for Non-streaming / Streaming / End-to-end)

Shared helpers and fake clients moved into ServedModelTestHelpers.cs.

Csproj net8.0+ exclusion list updated accordingly.

* .NET: Consolidate served-model logic into FoundryChatClient

Move x-ms-served-model header capture from the standalone ServedModelChatClient
decorator directly into FoundryChatClient, eliminating a separate wrapper that
had to be applied at every Foundry entry point via WireServedModel().

- Register ServedModelPolicy in FoundryChatClient constructors (alongside the
  existing AgentFrameworkUserAgentPolicy registration)
- Add StrongBox push/read logic to FoundryChatClient.GetResponseAsync and
  GetStreamingResponseAsync
- Delete ServedModelChatClient.cs and its unit tests
- Remove WireServedModel() from FoundryAgent and AIProjectClientExtensions
- Update ServedModelPolicy/Scope XML docs to reference FoundryChatClient
- Simplify ServedModelTestHelpers to use FoundryChatClient directly

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-21 21:26:42 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
c8b8198af1 Python: Prevent duplicate system instructions in Python telemetry (#5981)
* Initial plan

* Fix duplicated system instructions in Python telemetry

* Clarify telemetry message filtering

* test: cover separate and in-history system messages

* Clarify observability message logging split

* Simplify observability logging serialization

* Harden observability regression test

* Reuse observability span message serialization

* Clarify observability logging loops

* Polish observability message serialization

* Tighten observability zip checks

* Refactor observability message capture loop

* Fix telemetry logging for separate system instructions

* Refine observability OTEL message typing

* Restore prepended-instruction logging path in _capture_messages

* Revert logging change in _capture_messages; keep chat-history-only logging

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-21 19:59:06 +00:00
westeyandGitHub bda40ba0e1 .NET: Add shell support to the HarnessAgent (#6005)
* Add shell support to the HarnessAgent

* Address PR comments

* Address PR comments
2026-05-21 17:25:33 +00:00
Yufeng HeandGitHub 46ed66cfd5 Python: include tool definitions for Foundry agent evals (#5974) 2026-05-21 16:23:36 +00:00
126 changed files with 7110 additions and 253 deletions
+1
View File
@@ -137,6 +137,7 @@ jobs:
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
+4
View File
@@ -212,6 +212,7 @@
</Folder>
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
@@ -281,6 +282,7 @@
</Folder>
<Folder Name="/Samples/03-workflows/Orchestration/">
<Project Path="samples/03-workflows/Orchestration/Handoff/Handoff.csproj" />
<Project Path="samples/03-workflows/Orchestration/Magentic/Magentic.csproj" />
</Folder>
<Folder Name="/Samples/03-workflows/Observability/">
<Project Path="samples/03-workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
@@ -601,6 +603,7 @@
<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.Mcp/Microsoft.Agents.AI.Mcp.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" />
@@ -654,6 +657,7 @@
<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.Mcp.UnitTests/Microsoft.Agents.AI.Mcp.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" />
@@ -1151,6 +1151,25 @@ internal static class AgentsSamples
SkipReason = "Runs as an MCP stdio server that does not exit on its own.",
},
new SampleDefinition
{
Name = "Agent_MCP_LongRunningTask_Client",
ProjectPath = "samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client",
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
MustContain =
[
"=== Transparent long-running MCP task (RunAsync) ===",
"=== Transparent long-running MCP task (RunStreamingAsync) ===",
],
ExpectedOutputDescription =
[
"The output should show an agent analyzing a dataset named 'sales-2025-q1' and producing a summary mentioning rows, revenue, anomalies, or outliers.",
"The output should contain both a non-streaming response (after RunAsync) and a streaming response (after RunStreamingAsync) for the same analysis question.",
"The output should not contain error messages or stack traces.",
],
},
new SampleDefinition
{
Name = "AGUI_Step01_GettingStarted_Client",
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.6.1</VersionPrefix>
<VersionPrefix>1.6.2</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260514</DateSuffix>
<DateSuffix>260521</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.6.1</GitTag>
<GitTag>1.6.2</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001;MEAI001;MCPEXP001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,145 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates the Microsoft Agent Framework's MCP long-running task support.
//
// A small MCP server (hosted in this same executable when launched with "--server") exposes
// a single task-supporting tool "AnalyzeDataset" that simulates ~15 seconds of work. The
// client (default mode) connects to it over stdio via Microsoft.Agents.AI.Mcp's
// McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync, hands the wrapped tools to a
// ChatClientAgent, and exercises both invocation styles:
// * RunAsync — blocks until the agent's final response is ready.
// * RunStreamingAsync — yields response updates as the model produces them; the model
// still waits for the tool's terminal result before it can begin
// producing the final answer, so the perceived "pause" reflects
// tool execution time, not stream-channel latency.
//
// In both cases the wrapper transparently:
// 1. Calls tools/call with task augmentation (CallToolAsTaskAsync)
// 2. Polls tasks/get until terminal (PollTaskUntilCompleteAsync)
// 3. Fetches tasks/result and returns the final result to the function-calling loop
//
// No application-level loop or continuation tokens are required in either mode.
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Mcp;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using OpenAI.Chat;
if (args.Length > 0 && args[0] == "--server")
{
await RunMcpServerAsync();
return;
}
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";
// Launch this same assembly as a stdio MCP server in a child process.
var thisAssemblyPath = typeof(Program).Assembly.Location;
await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport(new()
{
Name = "DatasetAnalyzer",
Command = "dotnet",
Arguments = [thisAssemblyPath, "--server"],
}));
// Wrap each MCP tool with task-aware behavior. The wrapper inspects the server's
// execution.taskSupport on each tool and, when it is Required, drives the task lifecycle
// transparently within the agent's tool loop. Tools that don't require task semantics are
// returned as-is and invoked inline.
var taskOptions = new McpTaskOptions
{
DefaultTimeToLive = TimeSpan.FromMinutes(5),
};
var mcpTools = await mcpClient.ListAgentToolsWithTaskSupportAsync(taskOptions);
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(
instructions: "You answer data-analysis questions by invoking the available tools. Always invoke a tool when one matches the request.",
tools: [.. mcpTools.Cast<AITool>()]);
const string Prompt = "Analyze the dataset named 'sales-2025-q1' and summarize the findings.";
Console.WriteLine("=== Transparent long-running MCP task (RunAsync) ===");
Console.WriteLine("Asking the agent to analyze a dataset; the tool takes ~15s to complete.");
Console.WriteLine("RunAsync blocks while the wrapper polls the task to completion.");
Console.WriteLine();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await agent.RunAsync(Prompt);
stopwatch.Stop();
Console.WriteLine($"Agent response (after {stopwatch.Elapsed.TotalSeconds:F1}s):");
Console.WriteLine(response.Text);
Console.WriteLine();
Console.WriteLine("=== Transparent long-running MCP task (RunStreamingAsync) ===");
Console.WriteLine("Same request via the streaming API. Updates only begin to arrive after the");
Console.WriteLine("tool's task reaches the Completed state, since the model needs the tool result");
Console.WriteLine("before it can produce its final answer.");
Console.WriteLine();
stopwatch.Restart();
await foreach (var update in agent.RunStreamingAsync(Prompt))
{
Console.Write(update.Text);
}
stopwatch.Stop();
Console.WriteLine();
Console.WriteLine($"(Streaming completed after {stopwatch.Elapsed.TotalSeconds:F1}s.)");
// --- Server mode (launched as a child process via --server) ---------------------------------
static async Task RunMcpServerAsync()
{
var builder = Host.CreateApplicationBuilder();
// Critical for stdio transport: any provider that writes to stdout will corrupt the
// JSON-RPC channel. Clear all providers; the MCP SDK routes its own diagnostics
// appropriately.
builder.Logging.ClearProviders();
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddMcpServer(o =>
{
o.TaskStore = new InMemoryMcpTaskStore();
o.ServerInfo = new Implementation { Name = "DatasetAnalyzer", Version = "1.0.0" };
})
.WithStdioServerTransport()
.WithTools<DatasetAnalysisTools>();
await builder.Build().RunAsync();
}
#pragma warning disable CA1812 // Discovered by MCP SDK via [McpServerToolType] attribute
[McpServerToolType]
internal sealed class DatasetAnalysisTools
#pragma warning restore CA1812
{
[McpServerTool(Name = "AnalyzeDataset", TaskSupport = ToolTaskSupport.Required)]
[Description("Analyze a tabular dataset and return summary statistics. This tool simulates a long-running analytic job (~15 seconds).")]
public static async Task<string> AnalyzeDatasetAsync(
[Description("The dataset identifier, e.g. 'sales-2025-q1'.")] string datasetName,
CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false);
return $"Findings for '{datasetName}': 12,403 rows; avg revenue $48,712; 3 anomalies detected in week 7; outliers concentrated in EMEA region.";
}
}
@@ -0,0 +1,60 @@
# Agent with MCP long-running task (transparent polling)
This sample demonstrates Microsoft Agent Framework's MCP long-running task support: an agent invokes an MCP tool whose execution takes too long for a single request/response cycle, and the framework polls it to completion behind the function-calling loop. From the agent's perspective the tool simply returns its result.
## What this sample shows
- Using `McpClient.ListAgentToolsWithTaskSupportAsync(...)` (in `Microsoft.Agents.AI.Mcp`) to wrap MCP tools with task-aware behavior.
- Configuring `McpTaskOptions.DefaultTimeToLive` to bound the server-side task.
- Hosting a small MCP server (in this same executable, launched with `--server`) that advertises `execution.taskSupport=required` on a tool that sleeps for ~15 seconds.
- No application-level polling, continuation tokens, or `AllowBackgroundResponses` flag are required.
The decorator drives the lifecycle internally:
1. `tools/call` augmented with task metadata (`CallToolAsTaskAsync`)
2. `tasks/get` polled until terminal (`PollTaskUntilCompleteAsync`)
3. `tasks/result` retrieved (`GetTaskResultAsync`) and returned to the function-calling loop
The sample exercises both invocation styles against the same wrapper:
- `agent.RunAsync(...)` blocks until the tool completes (~15 seconds in this sample) and returns the final response.
- `agent.RunStreamingAsync(...)` returns immediately and yields `AgentResponseUpdate` chunks as the model emits them; in this scenario the model only begins streaming its answer once the wrapped tool's task reaches the `Completed` state, so the perceived "pause" before tokens arrive reflects tool execution time, not stream-channel latency.
# Prerequisites
- .NET 10 SDK or later
- Azure OpenAI service endpoint and a chat-completions deployment
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # optional; defaults to gpt-5.4-mini
```
# Running
```powershell
cd Agent_MCP_LongRunningTask_Client
dotnet run
```
You should see output similar to:
```
=== Transparent long-running MCP task (RunAsync) ===
Asking the agent to analyze a dataset; the tool takes ~15s to complete.
RunAsync blocks while the wrapper polls the task to completion.
Agent response (after 15.4s):
The 'sales-2025-q1' dataset contains 12,403 rows ...
=== Transparent long-running MCP task (RunStreamingAsync) ===
Same request via the streaming API. Updates only begin to arrive after the
tool's task reaches the Completed state, since the model needs the tool result
before it can produce its final answer.
The 'sales-2025-q1' dataset contains 12,403 rows ...
(Streaming completed after 15.7s.)
```
@@ -22,6 +22,7 @@ Before you begin, ensure you have the following prerequisites:
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|
## Running the samples from the console
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAIW001;OPENAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,193 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample ports the Python Magentic orchestration sample to .NET.
// A Magentic workflow coordinates a researcher and a coder, streams orchestration
// events as the plan evolves, and prints the final conversation transcript.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
using Microsoft.Extensions.AI;
namespace WorkflowMagenticOrchestrationSample;
/// <summary>
/// Demonstrates Magentic orchestration with a researcher, a coder, and an LLM manager.
/// </summary>
/// <remarks>
/// Pre-requisites:
/// - An Azure AI Foundry project endpoint and model deployment must be configured.
/// - Run <c>az login</c> before executing the sample.
/// </remarks>
public static class Program
{
private const string TaskPrompt =
"I am preparing a report on the energy efficiency of different machine learning model architectures. " +
"Compare the estimated training and inference energy consumption of ResNet-50, BERT-base, and GPT-2 " +
"on standard datasets (e.g., ImageNet for ResNet, GLUE for BERT, WebText for GPT-2). " +
"Then, estimate the CO2 emissions associated with each, assuming training on an Azure Standard_NC6s_v3 " +
"VM for 24 hours. Provide tables for clarity, and recommend the most energy-efficient model " +
"per task type (image classification, text classification, and text generation).";
private static async Task Main()
{
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";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
AIAgent researcherAgent = projectClient.AsAIAgent(
deploymentName,
name: "ResearcherAgent",
description: "Specialist in research and information gathering.",
instructions: "You are a researcher. Find relevant information without doing additional computation or quantitative analysis.");
AIAgent coderAgent = projectClient.AsAIAgent(
deploymentName,
name: "CoderAgent",
description: "A helpful assistant that writes and executes code to analyze data.",
instructions: "You solve quantitative questions by writing and running code. Show the analysis and the computation process clearly.",
tools: [new HostedCodeInterpreterTool()]);
AIAgent managerAgent = projectClient.AsAIAgent(
deploymentName,
name: "MagenticManager",
description: "Orchestrator that coordinates the research and coding workflow.",
instructions: "You coordinate the team to complete complex tasks efficiently.");
Workflow workflow = new MagenticWorkflowBuilder(managerAgent)
.AddParticipants([researcherAgent, coderAgent])
.WithName("Magentic Orchestration Workflow")
.WithDescription("Coordinates a researcher and coder to solve a complex analytical task.")
.RequirePlanSignoff(false)
.WithMaxRounds(10)
.WithMaxStalls(3)
.WithMaxResets(2)
.Build();
Console.WriteLine("Building Magentic workflow...");
Console.WriteLine();
Console.WriteLine($"Task: {TaskPrompt}");
Console.WriteLine();
Console.WriteLine("Starting workflow execution...");
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(
workflow,
new List<ChatMessage> { new(ChatRole.User, TaskPrompt) });
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
string? lastResponseId = null;
WorkflowOutputEvent? finalOutput = null;
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
{
switch (workflowEvent)
{
case AgentResponseUpdateEvent updateEvent:
WriteStreamingUpdate(updateEvent, ref lastResponseId);
break;
case MagenticPlanCreatedEvent planCreated:
WriteMagenticMessage("Initial Plan", planCreated.FullTaskLedger.Text);
PauseIfInteractive();
break;
case MagenticReplannedEvent replanned:
WriteMagenticMessage("Replanned", replanned.FullTaskLedger.Text);
PauseIfInteractive();
break;
case MagenticProgressLedgerUpdatedEvent progressUpdated:
WriteMagenticMessage("Progress Ledger", FormatProgressLedger(progressUpdated.ProgressLedger));
PauseIfInteractive();
break;
case WorkflowOutputEvent outputEvent when outputEvent.Is<List<ChatMessage>>():
finalOutput = outputEvent;
break;
case WorkflowErrorEvent workflowError:
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine(workflowError.Exception?.ToString() ?? "Unknown workflow error occurred.");
Console.ResetColor();
break;
case ExecutorFailedEvent executorFailed:
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Executor '{executorFailed.ExecutorId}' failed with {(executorFailed.Data is null ? "unknown error" : $"exception {executorFailed.Data}")}.");
Console.ResetColor();
break;
}
}
if (finalOutput?.As<List<ChatMessage>>() is { } transcript)
{
Console.WriteLine();
Console.WriteLine(new string('=', 80));
Console.WriteLine();
Console.WriteLine("Final Conversation Transcript:");
Console.WriteLine();
foreach (ChatMessage message in transcript)
{
Console.WriteLine($"{message.AuthorName ?? message.Role.ToString()}: {message.Text}");
Console.WriteLine();
}
}
}
private static void WriteStreamingUpdate(AgentResponseUpdateEvent updateEvent, ref string? lastResponseId)
{
string responseId = updateEvent.Update.ResponseId ?? updateEvent.Update.MessageId ?? updateEvent.ExecutorId;
if (!string.Equals(responseId, lastResponseId, StringComparison.Ordinal))
{
if (lastResponseId is not null)
{
Console.WriteLine();
Console.WriteLine();
}
Console.Write($"- {updateEvent.ExecutorId}: ");
lastResponseId = responseId;
}
if (!string.IsNullOrEmpty(updateEvent.Update.Text))
{
Console.Write(updateEvent.Update.Text);
}
}
private static void WriteMagenticMessage(string title, string? content)
{
Console.WriteLine();
Console.WriteLine($"[Magentic {title}]");
Console.WriteLine(content);
}
private static string FormatProgressLedger(MagenticProgressLedger ledger) =>
string.Join(Environment.NewLine,
$"Request satisfied: {ledger.IsRequestSatisfied}",
$"In loop: {ledger.IsInLoop}",
$"Making progress: {ledger.IsProgressBeingMade}",
$"Next speaker: {ledger.NextSpeaker}",
$"Instruction: {ledger.InstructionOrQuestion}");
private static void PauseIfInteractive()
{
if (Console.IsInputRedirected || Console.IsOutputRedirected)
{
return;
}
Console.Write("Press Enter to continue...");
Console.ReadLine();
Console.WriteLine();
}
}
@@ -0,0 +1,40 @@
# Magentic Orchestration Sample
This sample showcases the Magentic Orchestration Pattern in .NET, setting up a team with three roles:
- **ResearcherAgent** gathers factual background information.
- **CoderAgent** uses `HostedCodeInterpreterTool` for quantitative analysis.
- **MagenticManager** plans the work, tracks progress, and decides who should act next.
## What This Sample Demonstrates
- Building a Magentic workflow with `MagenticWorkflowBuilder`
- Combining standard responses-based agents with a code interpreter-enabled participant
- Streaming orchestration events such as the initial plan, replans, and progress-ledger updates
- Printing the final multi-agent conversation transcript
## Prerequisites
- `AZURE_AI_PROJECT_ENDPOINT` set to your Azure AI Foundry project endpoint
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` set to your model deployment name (defaults to `gpt-5.4-mini`)
- `az login` completed before running the sample
## Running the Sample
```bash
dotnet run
```
## Expected Output
The sample prints:
1. The original task prompt
2. Streamed updates from the participating agents
3. Magentic plan and progress-ledger events as the workflow coordinates the team
4. The final conversation transcript returned by the workflow
## Related Samples
- [Handoff Orchestration](../Handoff) - another multi-agent orchestration pattern in .NET workflows
- [Python Magentic workflow sample](../../../../../python/samples/03-workflows/orchestrations/magentic.py) - the source scenario that this sample ports
+1
View File
@@ -62,3 +62,4 @@ Once completed, please proceed to the other samples listed below.
| Sample | Concepts |
|--------|----------|
| [Handoff Orchestration](./Orchestration/Handoff) | Introduces the Handoff Orchestration pattern |
| [Magentic Orchestration](./Orchestration/Magentic) | Coordinates multiple agents with a Magentic manager, streamed plan events, and a final transcript |
@@ -474,6 +474,7 @@ public sealed class A2AAgent : AIAgent
ResponseId = statusUpdateEvent.TaskId,
RawRepresentation = statusUpdateEvent,
Role = ChatRole.Assistant,
MessageId = statusUpdateEvent.Status.Message?.MessageId,
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
Contents = statusUpdateEvent.Status.GetUserInputRequests(),
@@ -27,9 +27,9 @@ namespace Microsoft.Agents.AI.Foundry;
/// Foundry chat-client decorator that unifies the three Foundry chat-client construction
/// modes (Responses Agent, Prompt Agent, Agent Endpoint) behind a single type and centralizes
/// Foundry-specific concerns: <c>microsoft.foundry</c> telemetry tagging,
/// <c>agent-framework-dotnet/{version}</c> User-Agent stamping, and (for Prompt Agents)
/// per-request payload mutation that injects the agent reference and strips per-request
/// overrides that the server owns.
/// <c>agent-framework-dotnet/{version}</c> User-Agent stamping, <c>x-ms-served-model</c>
/// response-header capture, and (for Prompt Agents) per-request payload mutation that injects
/// the agent reference and strips per-request overrides that the server owns.
/// </summary>
/// <remarks>
/// <para>
@@ -78,6 +78,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
this._aiProjectClient = aiProjectClient;
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId);
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
TryRegisterServedModelPolicy(this.InnerClient);
}
/// <summary>
@@ -96,6 +97,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
this._baseChatOptions = baseChatOptions;
this.AgentName = agentReference.Name;
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
TryRegisterServedModelPolicy(this.InnerClient);
}
/// <summary>
@@ -161,6 +163,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
this.AgentName = inner.AgentName;
this._metadata = new ChatClientMetadata("microsoft.foundry");
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
TryRegisterServedModelPolicy(this.InnerClient);
}
/// <summary>
@@ -212,7 +215,25 @@ public sealed class FoundryChatClient : DelegatingChatClient
? this.GetAgentEnabledChatOptions(options)
: options;
return await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
var box = new StrongBox<string?>(null);
var previous = ServedModelScope.Current;
ServedModelScope.Current = box;
try
{
var response = await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
if (box.Value is { } servedModel)
{
response.ModelId = servedModel;
}
return response;
}
finally
{
ServedModelScope.Current = previous;
}
}
/// <inheritdoc/>
@@ -222,9 +243,25 @@ public sealed class FoundryChatClient : DelegatingChatClient
? this.GetAgentEnabledChatOptions(options)
: options;
await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
var box = new StrongBox<string?>(null);
var previous = ServedModelScope.Current;
ServedModelScope.Current = box;
try
{
yield return chunk;
await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
{
if (box.Value is { } servedModel)
{
chunk.ModelId = servedModel;
}
yield return chunk;
}
}
finally
{
ServedModelScope.Current = previous;
}
}
@@ -628,6 +665,25 @@ public sealed class FoundryChatClient : DelegatingChatClient
}
}
/// <summary>
/// Best-effort registration of <see cref="ServedModelPolicy"/> via the MEAI
/// <see cref="OpenAIRequestPolicies"/> hook. The policy captures the
/// <c>x-ms-served-model</c> response header from Azure OpenAI and writes it into
/// <see cref="ServedModelScope"/> so the <see cref="GetResponseAsync"/> and
/// <see cref="GetStreamingResponseAsync"/> overrides can overwrite
/// <see cref="ChatResponse.ModelId"/> with the actual model snapshot.
/// </summary>
private static void TryRegisterServedModelPolicy(IChatClient? innerClient)
{
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ServedModelPolicy.Instance,
PipelinePosition.PerCall);
}
}
/// <summary>Default OAuth scope for the Azure AI resource. Matches the scope used by <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is accepted by the Foundry control plane.</summary>
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Pipeline policy that captures the <c>x-ms-served-model</c> response header from Azure OpenAI
/// and stores it in <see cref="ServedModelScope"/> for consumption by <see cref="FoundryChatClient"/>.
/// </summary>
/// <remarks>
/// <para>
/// Azure OpenAI Responses API returns the deployment alias in <c>response.model</c> but the actual
/// model snapshot (e.g. <c>gpt-5-nano-2025-08-07</c>) in the <c>x-ms-served-model</c> response header.
/// This policy extracts the header after the HTTP roundtrip so the <see cref="FoundryChatClient"/>
/// can overwrite <c>ChatResponse.ModelId</c> with the true model name.
/// </para>
/// <para>
/// Registered once per <c>OpenAIRequestPolicies</c> instance via the MEAI 10.5.1 extension hook.
/// When the header is absent (non-Azure endpoints), the scope is not set and the
/// <see cref="FoundryChatClient"/> preserves the original model name.
/// </para>
/// </remarks>
internal sealed class ServedModelPolicy : PipelinePolicy
{
/// <summary>The Azure OpenAI response header that carries the actual served model name.</summary>
internal const string ServedModelHeader = "x-ms-served-model";
public static ServedModelPolicy Instance { get; } = new ServedModelPolicy();
private ServedModelPolicy()
{
}
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
ProcessNext(message, pipeline, currentIndex);
CaptureServedModel(message);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
CaptureServedModel(message);
}
private static void CaptureServedModel(PipelineMessage message)
{
if (message.Response is null)
{
return;
}
if (message.Response.Headers.TryGetValue(ServedModelHeader, out string? servedModel)
&& !string.IsNullOrWhiteSpace(servedModel))
{
// Write into the box (reference-type mutation) so the value is visible to the
// FoundryChatClient that pushed the box before calling the inner client.
if (ServedModelScope.Current is { } box)
{
box.Value = servedModel.Trim();
}
}
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using System.Threading;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// AsyncLocal carrier that bridges the <c>x-ms-served-model</c> response header value from the
/// <see cref="ServedModelPolicy"/> running inside the SCM transport pipeline up to the
/// <see cref="FoundryChatClient"/> decorator.
/// </summary>
/// <remarks>
/// <para>
/// Because <see cref="AsyncLocal{T}"/> mutations inside a child <c>async</c> method do not propagate
/// back to the caller (copy-on-write semantics), this scope uses <see cref="StrongBox{T}"/> as an
/// indirection layer. The <see cref="FoundryChatClient"/> pushes a fresh box onto the scope
/// before calling the inner client; the <see cref="ServedModelPolicy"/> writes into the box's
/// <see cref="StrongBox{T}.Value"/> (a reference-type mutation visible to anyone holding the same box).
/// After the inner call returns, the client reads the box's value.
/// </para>
/// </remarks>
internal static class ServedModelScope
{
private static readonly AsyncLocal<StrongBox<string?>?> s_current = new();
/// <summary>
/// Gets or sets the per-async-flow served model box.
/// </summary>
public static StrongBox<string?>? Current
{
get => s_current.Value;
set => s_current.Value = value;
}
}
@@ -6,6 +6,9 @@ using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Microsoft.Agents.AI.Compaction;
#if NET
using Microsoft.Agents.AI.Tools.Shell;
#endif
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
@@ -201,6 +204,14 @@ public sealed class HarnessAgent : DelegatingAIAgent
result.Tools.Add(new HostedWebSearchTool());
}
#if NET
if (options?.ShellExecutor is ShellExecutor shellExecutor)
{
result.Tools ??= [];
result.Tools.Add(shellExecutor.AsAIFunction());
}
#endif
return result;
}
@@ -259,6 +270,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
}
}
#if NET
if (options?.ShellExecutor is ShellExecutor shellExecutor)
{
providers.Add(new ShellEnvironmentProvider(shellExecutor, options.ShellEnvironmentProviderOptions));
}
#endif
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
{
providers.AddRange(userProviders);
@@ -2,6 +2,9 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
#if NET
using Microsoft.Agents.AI.Tools.Shell;
#endif
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
@@ -240,4 +243,27 @@ public sealed class HarnessAgentOptions
/// This property is ignored when <see cref="BackgroundAgents"/> is <see langword="null"/> or empty.
/// </remarks>
public BackgroundAgentsProviderOptions? BackgroundAgentsProviderOptions { get; set; }
#if NET
/// <summary>
/// Gets or sets the shell executor used to enable shell tool and environment probing via <see cref="ShellEnvironmentProvider"/>.
/// </summary>
/// <remarks>
/// When non-null, a <see cref="ShellEnvironmentProvider"/> is automatically included in the agent's context
/// providers (injecting OS/shell/CWD information into the system prompt), and the executor's
/// <see cref="ShellExecutor.AsAIFunction"/> is registered as a callable tool.
/// When <see langword="null"/> (the default), no shell features are enabled.
/// </remarks>
public ShellExecutor? ShellExecutor { get; set; }
/// <summary>
/// Gets or sets optional configuration for the <see cref="ShellEnvironmentProvider"/>.
/// </summary>
/// <remarks>
/// Use this to customize which tools are probed, the probe timeout, shell family override,
/// or the instructions formatter.
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
/// </remarks>
public ShellEnvironmentProviderOptions? ShellEnvironmentProviderOptions { get; set; }
#endif
}
@@ -15,6 +15,10 @@
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<ProjectReference Include="..\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Harness</Title>
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Mcp;
/// <summary>
/// Extension methods on <see cref="McpClient"/> that expose MCP server tools to a Microsoft
/// Agent Framework agent with optional long-running task (SEP-2663) handling.
/// </summary>
public static class McpClientTaskExtensions
{
/// <summary>
/// Lists tools advertised by the connected MCP server and returns each as an
/// <see cref="AIFunction"/>. Tools that declare <see cref="ToolTaskSupport.Required"/>
/// are wrapped with task-aware behavior so an agent can transparently drive long-running
/// invocations. All other tools — including those that declare
/// <see cref="ToolTaskSupport.Optional"/> — are returned as-is, preserving inline
/// (synchronous) invocation semantics by default.
/// </summary>
/// <param name="client">The connected MCP client.</param>
/// <param name="options">
/// Options that control the task lifecycle for task-capable tools.
/// When <see langword="null"/>, defaults described on <see cref="McpTaskOptions"/> apply.
/// </param>
/// <param name="cancellationToken">Token used to cancel listing the server's tools.</param>
/// <returns>The tools, ready to pass to <c>AsAIAgent(tools: …)</c>.</returns>
public static async Task<IReadOnlyList<AIFunction>> ListAgentToolsWithTaskSupportAsync(
this McpClient client,
McpTaskOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(client);
McpTaskOptions effectiveOptions = options ?? new McpTaskOptions();
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
AIFunction[] result = new AIFunction[tools.Count];
for (int i = 0; i < tools.Count; i++)
{
ToolTaskSupport? taskSupport = tools[i].ProtocolTool.Execution?.TaskSupport;
if (taskSupport is ToolTaskSupport.Required)
{
result[i] = new TaskAwareMcpClientAIFunction(client, tools[i], effectiveOptions);
}
else
{
result[i] = tools[i];
}
}
return result;
}
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Mcp;
/// <summary>
/// Configures how an MCP client wrapper drives the
/// <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks">MCP tasks</see>
/// lifecycle when an underlying server tool returns a <c>CreateTaskResult</c>.
/// </summary>
/// <remarks>
/// <para>
/// All members of this type are subject to change. The MCP task surface is experimental
/// and tracks the in-flight specification.
/// </para>
/// </remarks>
public sealed class McpTaskOptions
{
/// <summary>
/// Gets or sets the time-to-live the wrapper attaches to a newly created server-side task.
/// </summary>
/// <remarks>
/// When <see langword="null"/> the wrapper omits the <c>ttl</c> hint and lets the server
/// pick its own value. The server's chosen TTL is always authoritative.
/// </remarks>
public TimeSpan? DefaultTimeToLive { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the wrapper should send
/// <c>tasks/cancel</c> when the local <see cref="System.Threading.CancellationToken"/>
/// fires during a tool invocation.
/// </summary>
/// <remarks>
/// Defaults to <see langword="true"/>: a local cancellation means "the caller is giving up
/// on this tool invocation" and the server-side task has no further consumer.
/// </remarks>
public bool CancelRemoteTaskOnLocalCancellation { get; set; } = true;
}
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI.Mcp</RootNamespace>
<VersionSuffix>alpha</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001;MCPEXP001</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<PropertyGroup>
<Title>Microsoft Agent Framework MCP</Title>
<Description>Provides Microsoft Agent Framework support for Model Context Protocol (MCP), including long-running task (SEP-2663) integration for MCP clients.</Description>
</PropertyGroup>
<!-- Disable package validation baseline until the first release -->
<PropertyGroup>
<PackageValidationBaselineVersion />
<EnablePackageValidation>false</EnablePackageValidation>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Mcp.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,147 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Mcp;
/// <summary>
/// An <see cref="AIFunction"/> wrapper around an <see cref="McpClientTool"/> that drives the
/// <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks">MCP long-running task</see>
/// lifecycle (SEP-2663) on behalf of the agent's tool loop.
/// </summary>
/// <remarks>
/// <para>
/// The wrapper invokes the tool with task augmentation via
/// <see cref="McpClient.CallToolAsTaskAsync"/>, polls to completion via
/// <see cref="McpClient.PollTaskUntilCompleteAsync"/>, and fetches the result via
/// <see cref="McpClient.GetTaskResultAsync"/>. The result is returned to the caller as a
/// <see cref="JsonElement"/> containing the serialized <see cref="CallToolResult"/> — the
/// same wire shape produced by <see cref="McpClientTool"/>.<see cref="AIFunction.InvokeAsync(AIFunctionArguments, CancellationToken)"/>
/// so that downstream <see cref="FunctionResultContent"/> serialization is byte-identical to
/// a non-task-augmented MCP tool call. The agent's function-calling loop is unaware that a
/// task was used.
/// </para>
/// <para>
/// This wrapper is intended to be applied only to tools whose
/// <see cref="ToolExecution.TaskSupport"/> is <see cref="ToolTaskSupport.Required"/>
/// (selected by <see cref="McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync"/>).
/// As a defensive fallback, if the server still rejects the task-augmented call with
/// <see cref="McpErrorCode.MethodNotFound"/> (e.g. because tool-level capabilities changed
/// between <c>tools/list</c> and invocation), the wrapper transparently falls back to a
/// non-augmented call through the inner <see cref="McpClientTool"/>.
/// </para>
/// </remarks>
internal sealed class TaskAwareMcpClientAIFunction : AIFunction
{
private readonly McpClient _client;
private readonly McpClientTool _inner;
private readonly McpTaskOptions _options;
internal TaskAwareMcpClientAIFunction(McpClient client, McpClientTool inner, McpTaskOptions options)
{
_ = Throw.IfNull(client);
_ = Throw.IfNull(inner);
_ = Throw.IfNull(options);
this._client = client;
this._inner = inner;
this._options = options;
}
/// <inheritdoc />
public override string Name => this._inner.Name;
/// <inheritdoc />
public override string Description => this._inner.Description;
/// <inheritdoc />
public override JsonElement JsonSchema => this._inner.JsonSchema;
/// <inheritdoc />
public override JsonElement? ReturnJsonSchema => this._inner.ReturnJsonSchema;
/// <inheritdoc />
public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions;
/// <inheritdoc />
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments,
CancellationToken cancellationToken)
{
_ = Throw.IfNull(arguments);
McpTaskMetadata? metadata = null;
if (this._options.DefaultTimeToLive is TimeSpan ttl)
{
metadata = new McpTaskMetadata { TimeToLive = ttl };
}
McpTask task;
try
{
task = await this._client.CallToolAsTaskAsync(
this._inner.Name,
arguments,
taskMetadata: metadata,
progress: null,
options: null,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.MethodNotFound)
{
// Defensive fallback: the server's advertised TaskSupport indicated this tool
// could be invoked as a task, but the server now rejects task augmentation for it
// (e.g. capability changed between tools/list and invocation). Fall back to a
// non-augmented call through the inner McpClientTool.
return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
return await this.PollAndRetrieveResultAsync(task.TaskId, cancellationToken).ConfigureAwait(false);
}
private async Task<JsonElement> PollAndRetrieveResultAsync(string taskId, CancellationToken cancellationToken)
{
try
{
McpTask terminal = await this._client.PollTaskUntilCompleteAsync(taskId, options: null, cancellationToken).ConfigureAwait(false);
return terminal.Status switch
{
McpTaskStatus.Completed => await this._client.GetTaskResultAsync(taskId, options: null, cancellationToken).ConfigureAwait(false),
McpTaskStatus.Cancelled => throw new OperationCanceledException(FormatTerminalStatusMessage(taskId, terminal)),
_ => throw new InvalidOperationException(FormatTerminalStatusMessage(taskId, terminal)),// Failed (or any future non-terminal-but-unhandled status that the poll loop returns).
};
}
catch (OperationCanceledException) when (this._options.CancelRemoteTaskOnLocalCancellation && cancellationToken.IsCancellationRequested)
{
await this.TryCancelTaskAsync(taskId).ConfigureAwait(false);
throw;
}
}
private static string FormatTerminalStatusMessage(string taskId, McpTask terminal)
=> string.IsNullOrEmpty(terminal.StatusMessage)
? $"MCP task '{taskId}' ended in terminal status '{terminal.Status}'."
: $"MCP task '{taskId}' ended in terminal status '{terminal.Status}': {terminal.StatusMessage}";
private async Task TryCancelTaskAsync(string taskId)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_ = await this._client.CancelTaskAsync(taskId, options: null, cts.Token).ConfigureAwait(false);
}
catch
{
// Best-effort cancellation; do not mask the original cancellation reason.
}
}
}
@@ -248,7 +248,7 @@ public sealed class DockerShellExecutor : ShellExecutor
/// Build the AIFunction for this tool.
/// </summary>
/// <remarks>
/// When <paramref name="requireApproval"/> is <see langword="null"/>
/// When <paramref name="requireApproval"/> is <see langword="true"/>
/// (the default), the returned function is wrapped in
/// <see cref="ApprovalRequiredAIFunction"/>. The caller must
/// explicitly pass <see langword="false"/> to opt out of approval
@@ -259,14 +259,12 @@ public sealed class DockerShellExecutor : ShellExecutor
/// <param name="name">Function name surfaced to the model.</param>
/// <param name="description">Function description for the model.</param>
/// <param name="requireApproval">
/// <see langword="true"/> or <see langword="null"/> (the default)
/// wraps the function in <see cref="ApprovalRequiredAIFunction"/>;
/// <see langword="true"/> (the default) wraps the function in
/// <see cref="ApprovalRequiredAIFunction"/>;
/// <see langword="false"/> opts out and returns the raw function.
/// </param>
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool? requireApproval = null)
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
{
var effectiveRequireApproval = requireApproval ?? true;
description ??=
"Execute a single shell command inside an isolated Docker container and return its " +
"stdout, stderr, and exit code. The container has no network, no host filesystem access " +
@@ -292,7 +290,7 @@ public sealed class DockerShellExecutor : ShellExecutor
},
new AIFunctionFactoryOptions { Name = name, Description = description });
return effectiveRequireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
return requireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
}
/// <summary>
@@ -325,7 +325,7 @@ public sealed class LocalShellExecutor : ShellExecutor
/// container where the tool itself is the boundary).
/// </param>
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
{
if (!requireApproval && !this._acknowledgeUnsafe)
{
@@ -3,6 +3,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Tools.Shell;
@@ -65,6 +66,20 @@ public abstract class ShellExecutor : IAsyncDisposable
/// <param name="cancellationToken">Cancellation token.</param>
public abstract Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default);
/// <summary>
/// Build an <see cref="AIFunction"/> bound to this executor, suitable for
/// registering with an agent as a callable tool.
/// </summary>
/// <param name="name">Function name visible to the model.</param>
/// <param name="description">Function description for the model.</param>
/// <param name="requireApproval">
/// When <see langword="true"/> (the default), wraps the function in
/// <see cref="ApprovalRequiredAIFunction"/> so every invocation requires
/// explicit user approval before executing.
/// </param>
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
public abstract AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true);
/// <inheritdoc />
public abstract ValueTask DisposeAsync();
}
@@ -22,9 +22,13 @@ internal static class AgentProviderExtensions
{
IAsyncEnumerable<AgentResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
// Enable "autoSend" behavior if this is the workflow conversation.
// Determine whether the target conversation is the workflow conversation
// (used below to decide whether to mirror messages into the workflow conversation
// when an agent runs against a different conversation). The caller's autoSend
// value is honored as-is — when the workflow.yaml specifies autoSend: false the
// raw agent output must not be streamed to the caller, even when the agent is
// running on the workflow conversation.
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
autoSend |= isWorkflowConversation;
// Process the agent response updates.
List<AgentResponseUpdate> updates = [];
@@ -71,6 +71,13 @@ internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResul
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Establish the Foundry ProductContext on the current async logical context before
// running any code that reads PropertyPath.VariableName / NamespaceAlias. ObjectModel
// resolves those lazily against AsyncLocal<ProductContext>; when the workflow is
// hosted (AsAIAgent + AddFoundryResponses) each HTTP request runs on a fresh logical
// context where the build-thread setting does not flow.
WorkflowDiagnostics.SetFoundryProduct();
if (this.Model.Disabled)
{
Debug.WriteLine($"DISABLED {this.GetType().Name} [{this.Id}]");
@@ -192,13 +192,16 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
private bool GetAutoSendValue()
{
if (this.AgentOutput?.AutoSend is null)
// AzureAgentOutput.AutoSend is never null — it returns a literal-false default
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
// explicit autoSend value from the implicit default, and treat the implicit
// default as autoSend = true (the historical behavior for actions that omit
// autoSend or have no output block at all).
if (this.AgentOutput is { AutoSendIsDefaultValue: false } output)
{
return true;
return this.Evaluator.GetValue(output.AutoSend).Value;
}
EvaluationResult<bool> autoSendResult = this.Evaluator.GetValue(this.AgentOutput.AutoSend);
return autoSendResult.Value;
return true;
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -103,6 +104,24 @@ internal sealed class InvokeFunctionToolExecutor(
FunctionResultContent? matchingResult = functionResults
.FirstOrDefault(r => r.CallId == this.Id);
// When the caller approved an approval-required function call but didn't execute it
// locally (the hosted Foundry scenario, where mcp_approval_response is converted to a
// ToolApprovalResponseContent only), invoke the registered AIFunction here so that the
// declarative workflow can capture the result and continue (e.g. for downstream
// SendActivity/PropertyPath consumers like {Local.Result}).
if (matchingResult is null)
{
ToolApprovalResponseContent? approval = response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalResponseContent>()
.FirstOrDefault(r => r.RequestId == this.Id);
if (approval is { Approved: true })
{
matchingResult = await this.InvokeRegisteredFunctionAsync(cancellationToken).ConfigureAwait(false);
}
}
if (matchingResult is not null)
{
// Store the result in output variable
@@ -241,6 +260,48 @@ internal sealed class InvokeFunctionToolExecutor(
return conversationIdValue.Length == 0 ? null : conversationIdValue;
}
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
{
string functionName = this.GetFunctionName();
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
if (function is null)
{
return new FunctionResultContent(this.Id, result: null)
{
Exception = new InvalidOperationException(
$"Function '{functionName}' is not registered with the agent provider."),
};
}
Dictionary<string, object?>? arguments = this.GetArguments();
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
object? result;
try
{
result = await function.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return new FunctionResultContent(this.Id, result: null) { Exception = ex };
}
// Match FunctionInvokingChatClient's serialization: pass strings through as-is and
// JSON-serialize anything else so structured results remain consumable by downstream
// PropertyPath consumers such as {Local.RefundResult}. Use AIJsonUtilities so the
// same trim/AOT-friendly serializer chain used elsewhere in the framework is applied.
string serialized = result switch
{
null => string.Empty,
string s => s,
_ => JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType())),
};
return new FunctionResultContent(this.Id, serialized);
}
private bool GetRequireApproval()
{
if (this.Model.RequireApproval is null)
@@ -253,12 +314,16 @@ internal sealed class InvokeFunctionToolExecutor(
private bool GetAutoSendValue()
{
if (this.Model.Output?.AutoSend is null)
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
// explicit autoSend value from the implicit default, and treat the implicit
// default as autoSend = true (the historical behavior).
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
{
return true;
return this.Evaluator.GetValue(output.AutoSend).Value;
}
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
return true;
}
private Dictionary<string, object?>? GetArguments()
@@ -311,12 +311,16 @@ internal sealed class InvokeMcpToolExecutor(
private bool GetAutoSendValue()
{
if (this.Model.Output?.AutoSend is null)
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
// explicit autoSend value from the implicit default, and treat the implicit
// default as autoSend = true (the historical behavior).
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
{
return true;
return this.Evaluator.GetValue(output.AutoSend).Value;
}
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
return true;
}
private string? GetConnectionName()
@@ -21,12 +21,21 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
ChatMessage message = new(ChatRole.Assistant, activityText);
// Emit an AgentResponseUpdateEvent so chat protocols (e.g. AsAIAgent) receive the
// activity text as streaming chat content. This event is yielded by WorkflowSession
// unconditionally, mirroring how AgentProviderExtensions surfaces autoSend agent
// updates — without it, SendActivity output is dropped whenever the host runs with
// includeWorkflowOutputsInResponse = false (the default).
AgentResponseUpdate update = new(ChatRole.Assistant, activityText) { AuthorName = this.Id };
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), 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)]);
// produce an AgentResponseEvent identical to the one we'd build by hand, which
// is the gated summary surfaced only when includeWorkflowOutputsInResponse = true.
AgentResponse response = new([message]);
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
@@ -70,8 +70,6 @@ internal class AIAgentHostExecutor : ChatProtocolExecutor
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
.YieldsOutput<AgentResponseUpdate>()
.YieldsOutput<AgentResponse>()
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ResetChatSignal>(this.ResetChat));
}
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Projects;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace Foundry.IntegrationTests;
/// <summary>
/// Integration tests validating that the <c>x-ms-served-model</c> response header
/// returned by the Azure OpenAI Responses API is surfaced on <see cref="ChatResponse.ModelId"/>.
/// </summary>
public class ResponsesAgentServedModelTests
{
// Matches a dated served-model snapshot, e.g. "gpt-5-nano-2025-08-07".
private static readonly Regex s_snapshotRegex = new(@"-\d{4}-\d{2}-\d{2}$", RegexOptions.Compiled);
private static Uri Endpoint => new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
private static string DeploymentName => TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName);
private readonly AIProjectClient _client = new(Endpoint, TestAzureCliCredentials.CreateAzureCliCredential());
[Fact]
public async Task GetResponseAsync_ReturnsServedModelSnapshotOnModelIdAsync()
{
// Arrange
ChatClientAgent agent = this._client.AsAIAgent(
model: DeploymentName,
instructions: "You are a helpful assistant. Reply with a single short word.",
name: "ServedModelTest");
IChatClient chatClient = agent.ChatClient;
// Act
ChatResponse response = await chatClient.GetResponseAsync(
[new ChatMessage(ChatRole.User, "Say hi.")],
new ChatOptions { ModelId = DeploymentName });
// Assert
AssertServedModel(response.ModelId);
}
[Fact]
public async Task RunAsync_AgentResponseRawRepresentationCarriesServedModelAsync()
{
// Arrange
ChatClientAgent agent = this._client.AsAIAgent(
model: DeploymentName,
instructions: "You are a helpful assistant. Reply with a single short word.",
name: "ServedModelTestRun");
// Act
AgentResponse agentResponse = await agent.RunAsync("Say hi.");
// Assert
ChatResponse? chatResponse = agentResponse.RawRepresentation as ChatResponse;
Assert.NotNull(chatResponse);
AssertServedModel(chatResponse!.ModelId);
}
private static void AssertServedModel(string? modelId)
{
Assert.False(string.IsNullOrWhiteSpace(modelId), "ChatResponse.ModelId must be populated.");
// Primary invariant: the served-model value must look like a dated snapshot
// (e.g. "gpt-5-nano-2025-08-07"). This is what the x-ms-served-model header carries.
// Only when the configured deployment name itself already matches the snapshot pattern
// do we fall back to permitting equality with the deployment alias.
bool aliasIsSnapshot = s_snapshotRegex.IsMatch(DeploymentName);
if (aliasIsSnapshot)
{
return;
}
Assert.Matches(s_snapshotRegex, modelId!);
Assert.NotEqual(DeploymentName, modelId);
}
}
@@ -1124,6 +1124,7 @@ public sealed class A2AAgentTests : IDisposable
Assert.Equal(TaskId, update0.ResponseId);
Assert.Equal(this._agent.Id, update0.AgentId);
Assert.Null(update0.FinishReason);
Assert.Null(update0.MessageId);
Assert.IsType<TaskStatusUpdateEvent>(update0.RawRepresentation);
// Assert - session should be updated with context and task IDs
@@ -1132,6 +1133,50 @@ public sealed class A2AAgentTests : IDisposable
Assert.Equal(TaskId, a2aSession.TaskId);
}
[Fact]
public async Task RunStreamingAsync_WithTaskStatusUpdateEventAndMessageId_YieldsMessageIdAsync()
{
// Arrange
const string TaskId = "task-status-msg-123";
const string ContextId = "ctx-status-msg-456";
const string ExpectedMessageId = "msg-status-789";
this._handler.StreamingResponseToReturn = new StreamResponse
{
StatusUpdate = new TaskStatusUpdateEvent
{
TaskId = TaskId,
ContextId = ContextId,
Status = new()
{
State = TaskState.Working,
Message = new Message
{
MessageId = ExpectedMessageId,
Parts = [Part.FromText("Processing your request...")]
}
}
}
};
var session = await this._agent.CreateSessionAsync();
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in this._agent.RunStreamingAsync("Check task status", session))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
var update0 = updates[0];
Assert.Equal(ExpectedMessageId, update0.MessageId);
Assert.Equal(TaskId, update0.ResponseId);
Assert.IsType<TaskStatusUpdateEvent>(update0.RawRepresentation);
}
[Fact]
public async Task RunStreamingAsync_WithInputRequiredStatusUpdate_YieldsStatusContentsAsync()
{
@@ -1150,6 +1195,7 @@ public sealed class A2AAgentTests : IDisposable
State = TaskState.InputRequired,
Message = new Message
{
MessageId = "input-msg-789",
Parts = [Part.FromText("Where would you like to fly?")]
}
}
@@ -1170,6 +1216,7 @@ public sealed class A2AAgentTests : IDisposable
var update0 = updates[0];
Assert.Equal(TaskId, update0.ResponseId);
Assert.Equal("input-msg-789", update0.MessageId);
Assert.Null(update0.FinishReason);
var textContent = Assert.Single(update0.Contents.OfType<TextContent>());
@@ -555,20 +555,20 @@ public sealed class FoundryChatClientTests
#endregion
#region AgentFrameworkUserAgentPolicy registration + dedup
#region AgentFrameworkUserAgentPolicy + ServedModelPolicy registration + dedup
[Fact]
public void Register_AgentFrameworkUserAgentPolicy_OnUnderlyingOpenAIRequestPolicies()
{
// Arrange + Act: constructing a FoundryChatClient should register the
// AgentFrameworkUserAgentPolicy on the inner chat client's OpenAIRequestPolicies.
// AgentFrameworkUserAgentPolicy and ServedModelPolicy on the inner chat client's OpenAIRequestPolicies.
var chatClient = new FoundryChatClient(CreateProjectClient(), "gpt-4o-mini");
// Assert: the inner chat client (MEAI's OpenAIResponsesChatClient) exposes
// OpenAIRequestPolicies via GetService, and our policy is present in its entries.
// OpenAIRequestPolicies via GetService, and both policies are present in its entries.
var policies = chatClient.GetService<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
Assert.Equal(2, EntriesCount(policies!));
}
[Fact]
@@ -576,7 +576,7 @@ public sealed class FoundryChatClientTests
{
// Arrange: construct via the ProjectsAgentVersion mode-2 variant, which chains via
// :this(...) into the AgentReference ctor. If the policy registration code were
// inadvertently called twice along the chain, we would see 2 entries.
// inadvertently called twice along the chain, we would see more than 2 entries.
var projectClient = CreateProjectClient();
var agentVersion = ModelReaderWriter.Read<ProjectsAgentVersion>(
BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!;
@@ -585,10 +585,10 @@ public sealed class FoundryChatClientTests
var chatClient = new FoundryChatClient(projectClient, agentVersion, baseChatOptions: null);
// Assert: even though the version variant funnels through the AgentReference ctor
// via :this(...), the policy is registered exactly once on the inner pipeline.
// via :this(...), each policy is registered exactly once on the inner pipeline.
var policies = chatClient.GetService<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
Assert.Equal(2, EntriesCount(policies!));
Assert.Same(agentVersion, chatClient.GetService<ProjectsAgentVersion>());
Assert.NotNull(chatClient.GetService<AgentReference>());
}
@@ -14,11 +14,14 @@
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<!-- Tests requiring net8.0+ (MEAI.Evaluation and some SCM pipeline APIs do not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="FoundryEvalConverterTests.cs" />
<Compile Remove="FoundryEvalsTests.cs" />
<Compile Remove="ClientHeadersExtensionsTests.cs" />
<Compile Remove="ServedModelTestHelpers.cs" />
<Compile Remove="ServedModelScopeTests.cs" />
<Compile Remove="ServedModelPolicyTests.cs" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for <see cref="ServedModelPolicy"/>: the SCM pipeline policy that reads the
/// <c>x-ms-served-model</c> response header and writes it into the active
/// <see cref="ServedModelScope"/> box.
/// </summary>
/// <remarks>
/// Tests drive the policy through a real OpenAI ResponsesClient SCM pipeline against a mock
/// HTTP handler so the policy executes in its production configuration.
/// </remarks>
public sealed class ServedModelPolicyTests
{
[Fact]
public void Instance_IsSingleton()
{
Assert.Same(ServedModelPolicy.Instance, ServedModelPolicy.Instance);
}
[Fact]
public async Task ProcessAsync_HeaderPresent_SetsModelIdOnResponseAsync()
{
// Arrange
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07");
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
// Act
var response = await chatClient.GetResponseAsync("hi");
// Assert
Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId);
}
[Fact]
public async Task ProcessAsync_HeaderAbsent_PreservesModelIdFromBodyAsync()
{
// Arrange
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: null);
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
// Act
var response = await chatClient.GetResponseAsync("hi");
// Assert: ModelId is the deployment alias from the JSON body ("fake").
Assert.Equal("fake", response.ModelId);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task ProcessAsync_EmptyOrWhitespaceHeader_PreservesModelIdFromBodyAsync(string headerValue)
{
// Arrange
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: headerValue);
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
// Act
var response = await chatClient.GetResponseAsync("hi");
// Assert: empty/whitespace header is rejected by the policy, ModelId stays as "fake".
Assert.Equal("fake", response.ModelId);
}
[Fact]
public async Task ProcessAsync_HeaderWithSurroundingWhitespace_TrimsValueAsync()
{
// Arrange
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: " gpt-5-nano-2025-08-07 ");
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
// Act
var response = await chatClient.GetResponseAsync("hi");
// Assert
Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId);
}
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for <see cref="ServedModelScope"/>: the AsyncLocal carrier that bridges the
/// served-model value from the SCM pipeline policy up to the delegating chat client.
/// </summary>
public sealed class ServedModelScopeTests
{
[Fact]
public void Current_DefaultIsNull()
{
Assert.Null(ServedModelScope.Current);
}
[Fact]
public void Current_SetAndGet_ReturnsBox()
{
// Arrange
var previous = ServedModelScope.Current;
try
{
// Act
var box = new StrongBox<string?>("gpt-5-nano-2025-08-07");
ServedModelScope.Current = box;
// Assert
Assert.Same(box, ServedModelScope.Current);
Assert.Equal("gpt-5-nano-2025-08-07", ServedModelScope.Current!.Value);
}
finally
{
ServedModelScope.Current = previous;
}
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Shared helpers and fake clients used by the served-model test suite
/// (<see cref="ServedModelScopeTests"/>, <see cref="ServedModelPolicyTests"/>).
/// </summary>
internal static class ServedModelTestHelpers
{
public static string MinimalResponseJson() => """
{
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}
""";
/// <summary>
/// Creates a <see cref="FoundryChatClient"/> backed by a real OpenAI Responses pipeline
/// routed through the supplied <paramref name="handler"/>. The <see cref="ServedModelPolicy"/>
/// is registered automatically by the <see cref="FoundryChatClient"/> constructor.
/// </summary>
public static IChatClient CreateChatClientWithPolicy(HttpMessageHandler handler)
{
#pragma warning disable CA5399
var http = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) });
return new FoundryChatClient(projectClient, "fake");
}
/// <summary>
/// An <see cref="HttpClientHandler"/> that returns a fixed response body and optionally
/// includes the <c>x-ms-served-model</c> response header.
/// </summary>
public sealed class ServedModelHandler : HttpClientHandler
{
private readonly string _body;
private readonly string? _servedModel;
public ServedModelHandler(string body, string? servedModel)
{
this._body = body;
this._servedModel = servedModel;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var resp = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
RequestMessage = request,
};
if (this._servedModel is not null)
{
resp.Headers.Add("x-ms-served-model", this._servedModel);
}
return Task.FromResult(resp);
}
}
}
@@ -1,6 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using Moq;
#if NET
using Microsoft.Agents.AI.Tools.Shell;
#endif
namespace Microsoft.Agents.AI.UnitTests;
@@ -39,6 +42,10 @@ public class HarnessAgentOptionsTests
Assert.Null(options.AgentSkillsSource);
Assert.Null(options.BackgroundAgents);
Assert.Null(options.BackgroundAgentsProviderOptions);
#if NET
Assert.Null(options.ShellExecutor);
Assert.Null(options.ShellEnvironmentProviderOptions);
#endif
}
/// <summary>
@@ -56,6 +63,10 @@ public class HarnessAgentOptionsTests
var skillsSource = new Mock<AgentSkillsSource>().Object;
var backgroundAgents = new AIAgent[] { new Mock<AIAgent>().Object };
var backgroundAgentsOptions = new BackgroundAgentsProviderOptions();
#if NET
var shellExecutor = new Mock<ShellExecutor>().Object;
var shellEnvOptions = new ShellEnvironmentProviderOptions();
#endif
// Act
var options = new HarnessAgentOptions
@@ -83,6 +94,10 @@ public class HarnessAgentOptionsTests
OpenTelemetrySourceName = "custom-source",
BackgroundAgents = backgroundAgents,
BackgroundAgentsProviderOptions = backgroundAgentsOptions,
#if NET
ShellExecutor = shellExecutor,
ShellEnvironmentProviderOptions = shellEnvOptions,
#endif
};
// Assert
@@ -111,5 +126,9 @@ public class HarnessAgentOptionsTests
Assert.Equal("custom-source", options.OpenTelemetrySourceName);
Assert.Same(backgroundAgents, options.BackgroundAgents);
Assert.Same(backgroundAgentsOptions, options.BackgroundAgentsProviderOptions);
#if NET
Assert.Same(shellExecutor, options.ShellExecutor);
Assert.Same(shellEnvOptions, options.ShellEnvironmentProviderOptions);
#endif
}
}
@@ -5,6 +5,9 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
#if NET
using Microsoft.Agents.AI.Tools.Shell;
#endif
using Microsoft.Extensions.AI;
using Moq;
@@ -1347,4 +1350,114 @@ public class HarnessAgentTests
}
#endregion
#if NET
#region Feature: ShellEnvironmentProvider
/// <summary>
/// Verify that ShellEnvironmentProvider is included when ShellExecutor is provided.
/// </summary>
[Fact]
public void ShellEnvironmentProvider_IncludedWhenExecutorProvided()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var executorMock = new Mock<ShellExecutor>();
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
.Returns(AIFunctionFactory.Create(() => "test", "run_shell"));
var options = CreateAllDisabledOptions();
options.ShellExecutor = executorMock.Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is ShellEnvironmentProvider);
}
/// <summary>
/// Verify that ShellEnvironmentProvider is not included when ShellExecutor is null.
/// </summary>
[Fact]
public void ShellEnvironmentProvider_ExcludedWhenExecutorNull()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.ShellExecutor = null;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.AIContextProviders);
Assert.DoesNotContain(innerAgent.AIContextProviders!, p => p is ShellEnvironmentProvider);
}
/// <summary>
/// Verify that the shell tool AIFunction is added to ChatOptions.Tools when ShellExecutor is provided.
/// </summary>
[Fact]
public async Task ShellExecutor_ToolAddedToChatOptionsAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
var chatClientMock = new Mock<IChatClient>();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
var executorMock = new Mock<ShellExecutor>();
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
.Returns(AIFunctionFactory.Create(() => "shell output", "run_shell"));
var options = CreateAllDisabledOptions();
options.DisableWebSearch = true;
options.ShellExecutor = executorMock.Object;
// Act
var agent = new HarnessAgent(chatClientMock.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var session = await agent.CreateSessionAsync();
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — the shell tool should be present
Assert.NotNull(capturedOptions?.Tools);
Assert.Contains(capturedOptions!.Tools!, t => t is AIFunction f && f.Name == "run_shell");
}
/// <summary>
/// Verify that ShellEnvironmentProvider is present when ShellEnvironmentProviderOptions is also specified.
/// </summary>
[Fact]
public void ShellEnvironmentProvider_PresentWhenOptionsProvided()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var executorMock = new Mock<ShellExecutor>();
executorMock.Setup(e => e.AsAIFunction(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<bool>()))
.Returns(AIFunctionFactory.Create(() => "test", "run_shell"));
var envOptions = new ShellEnvironmentProviderOptions
{
ProbeTools = ["git", "python"],
};
var options = CreateAllDisabledOptions();
options.ShellExecutor = executorMock.Object;
options.ShellEnvironmentProviderOptions = envOptions;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — provider should exist (options wiring is validated by the provider's behavior)
Assert.NotNull(innerAgent?.AIContextProviders);
Assert.Contains(innerAgent!.AIContextProviders!, p => p is ShellEnvironmentProvider);
}
#endregion
#endif
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
/// <summary>
/// Minimal empty <see cref="IServiceProvider"/> for in-memory fixtures that don't use DI.
/// </summary>
internal sealed class EmptyServiceProvider : IServiceProvider
{
public static EmptyServiceProvider Instance { get; } = new();
public object? GetService(Type serviceType) => null;
}
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.IO.Pipelines;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
/// <summary>
/// In-process MCP server fixture that pairs a <see cref="McpServer"/> and a <see cref="McpClient"/>
/// over duplex <see cref="Pipe"/>-backed streams so unit tests can exercise the
/// real task-augmentation protocol without spawning a child process or opening a socket.
/// </summary>
internal sealed class InMemoryMcpServerFixture : IAsyncDisposable
{
private readonly McpServer _server;
private readonly Task _serverLoop;
private readonly CancellationTokenSource _cts;
public McpClient Client { get; }
private InMemoryMcpServerFixture(McpServer server, McpClient client, Task serverLoop, CancellationTokenSource cts)
{
this._server = server;
this.Client = client;
this._serverLoop = serverLoop;
this._cts = cts;
}
public static async Task<InMemoryMcpServerFixture> CreateAsync(
McpServerPrimitiveCollection<McpServerTool> tools,
CancellationToken cancellationToken = default)
{
Pipe clientToServer = new();
Pipe serverToClient = new();
// Stream conventions:
// StreamClientTransport(serverInput, serverOutput, ...): serverInput is what the client
// WRITES to (server reads it); serverOutput is what the client READS from (server writes it).
// StreamServerTransport(input, output, ...): input is what the server READS from; output
// is what the server WRITES to.
Stream clientWriteStream = clientToServer.Writer.AsStream();
Stream clientReadStream = serverToClient.Reader.AsStream();
Stream serverReadStream = clientToServer.Reader.AsStream();
Stream serverWriteStream = serverToClient.Writer.AsStream();
StreamServerTransport serverTransport = new(
serverReadStream,
serverWriteStream,
"test-server",
NullLoggerFactory.Instance);
McpServerOptions serverOptions = new()
{
ServerInfo = new Implementation { Name = "test-server", Version = "1.0.0" },
TaskStore = new InMemoryMcpTaskStore(),
ToolCollection = tools,
};
McpServer server = McpServer.Create(
serverTransport,
serverOptions,
NullLoggerFactory.Instance,
EmptyServiceProvider.Instance);
CancellationTokenSource cts = new();
Task serverLoop = Task.Run(() => server.RunAsync(cts.Token), cts.Token);
StreamClientTransport clientTransport = new(
clientWriteStream,
clientReadStream,
NullLoggerFactory.Instance);
McpClient client = await McpClient.CreateAsync(
clientTransport,
clientOptions: null,
NullLoggerFactory.Instance,
cancellationToken).ConfigureAwait(false);
return new InMemoryMcpServerFixture(server, client, serverLoop, cts);
}
public async ValueTask DisposeAsync()
{
try
{
await this.Client.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best effort.
}
this._cts.Cancel();
try
{
await this._serverLoop.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected.
}
catch
{
// Best effort.
}
try
{
await this._server.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best effort.
}
this._cts.Dispose();
}
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
public class ListAgentToolsWithTaskSupportTests
{
[Fact]
public async Task ListAgentToolsWithTaskSupport_WrapsTaskCapableTools_LeavesOthersAsIsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("opt", ToolTaskSupport.Optional, () => "opt-result"),
TestTools.Create("req", ToolTaskSupport.Required, () => "req-result"),
TestTools.Create("forb", ToolTaskSupport.Forbidden, () => "forb-result"),
TestTools.Create("none", taskSupport: null, () => "none-result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
// Act
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
// Assert
result.Should().HaveCount(4);
AIFunction opt = result.Single(f => f.Name == "opt");
AIFunction req = result.Single(f => f.Name == "req");
AIFunction forb = result.Single(f => f.Name == "forb");
AIFunction none = result.Single(f => f.Name == "none");
req.Should().BeOfType<TaskAwareMcpClientAIFunction>("Required tools must be wrapped");
opt.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Optional tools must not be wrapped; inline invocation is preserved by default");
forb.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Forbidden tools must not be wrapped");
none.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Tools without execution metadata must not be wrapped");
}
[Fact]
public async Task ListAgentToolsWithTaskSupport_ThrowsOnNullClientAsync()
{
// Arrange
ModelContextProtocol.Client.McpClient client = null!;
// Act
Func<Task> act = async () => await client.ListAgentToolsWithTaskSupportAsync();
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using FluentAssertions;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
public class McpTaskOptionsTests
{
[Fact]
public void Defaults_AreSane()
{
// Act
McpTaskOptions options = new();
// Assert
options.DefaultTimeToLive.Should().BeNull();
options.CancelRemoteTaskOnLocalCancellation.Should().BeTrue();
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
public class TaskAwareMcpClientAIFunctionTests
{
[Fact]
public async Task InvokeAsync_RequiredTool_HappyPath_ReturnsResultAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("req", ToolTaskSupport.Required, () => "required-result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
AIFunction req = result.Single(f => f.Name == "req");
req.Should().BeOfType<TaskAwareMcpClientAIFunction>();
// Act
object? invokeResult = await req.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
JsonElement payload = invokeResult.Should().BeOfType<JsonElement>().Subject;
ExtractTextContent(payload).Should().Be("required-result");
}
[Fact]
public async Task InvokeAsync_PropagatesDefaultTimeToLiveAsync()
{
// Arrange — capture the request meta on the server so we can assert TTL flowed through.
TimeSpan? observedTtl = null;
McpServerTool tool = McpServerTool.Create(
(RequestContext<CallToolRequestParams> ctx) =>
{
observedTtl = ctx.Params?.Task?.TimeToLive;
return "ok";
},
new McpServerToolCreateOptions
{
Name = "ttl-tool",
Description = "Echoes the requested TTL.",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
});
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
TimeSpan requestedTtl = TimeSpan.FromMinutes(7);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync(new McpTaskOptions { DefaultTimeToLive = requestedTtl });
AIFunction wrapped = result.Single();
// Act
_ = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
observedTtl.Should().Be(requestedTtl);
}
[Fact]
public async Task InvokeAsync_RespectsCancellationAsync()
{
// Arrange — a tool that never completes until it's cancelled.
var serverCancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
McpServerTool tool = McpServerTool.Create(
async (CancellationToken ct) =>
{
try
{
await Task.Delay(Timeout.Infinite, ct);
}
catch (OperationCanceledException)
{
serverCancelled.TrySetResult(true);
throw;
}
return "should-not-complete";
},
new McpServerToolCreateOptions
{
Name = "blocking",
Description = "Blocks indefinitely until cancelled.",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
});
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
AIFunction wrapped = result.Single();
using CancellationTokenSource cts = new();
// Act — start the invocation, cancel after a brief delay.
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, cts.Token).AsTask();
await Task.Delay(200);
cts.Cancel();
// Assert — wrapper observes cancellation and signals server-side cancellation.
Func<Task> awaitInvocation = async () => await invocation;
await awaitInvocation.Should().ThrowAsync<OperationCanceledException>();
// Server-side handler should have observed cancellation as a result of the wrapper's
// tasks/cancel call (best-effort wait — give the server-loop a few seconds).
Task observedTask = serverCancelled.Task;
Task completed = await Task.WhenAny(observedTask, Task.Delay(TimeSpan.FromSeconds(5)));
completed.Should().BeSameAs(observedTask, "the wrapper should have issued tasks/cancel");
}
[Fact]
public async Task InvokeAsync_FailedTask_ThrowsInvalidOperationAsync()
{
// Arrange — a tool whose handler throws, which the server surfaces as a Failed task.
McpServerTool tool = McpServerTool.Create(
(Func<string>)(() => throw new InvalidOperationException("simulated tool failure")),
new McpServerToolCreateOptions
{
Name = "boom",
Description = "Throws unconditionally.",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
});
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
AIFunction wrapped = result.Single();
// Act
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert — Phase 1 surfaces non-Completed terminal states as InvalidOperationException
// carrying the server's StatusMessage. (See PollAndRetrieveResultAsync.)
await act.Should().ThrowAsync<Exception>().Where(ex =>
ex is InvalidOperationException
|| ex.GetType().FullName == "ModelContextProtocol.McpException");
}
/// <summary>
/// Extracts the first text-content block from a serialized <c>CallToolResult</c>
/// (the JSON shape returned by the wrapper and by <c>McpClientTool.InvokeAsync</c>).
/// </summary>
private static string ExtractTextContent(JsonElement payload)
{
payload.ValueKind.Should().Be(JsonValueKind.Object);
JsonElement content = payload.GetProperty("content");
content.ValueKind.Should().Be(JsonValueKind.Array);
JsonElement firstBlock = content.EnumerateArray().First();
return firstBlock.GetProperty("text").GetString()!;
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
/// <summary>
/// Helpers to create <see cref="McpServerTool"/> instances with a specific
/// <see cref="ToolTaskSupport"/> level for in-memory fixtures.
/// </summary>
internal static class TestTools
{
public static McpServerTool Create(string name, ToolTaskSupport? taskSupport, Delegate handler)
{
McpServerToolCreateOptions options = new()
{
Name = name,
Description = $"Test tool {name}.",
};
if (taskSupport is ToolTaskSupport ts)
{
options.Execution = new ToolExecution { TaskSupport = ts };
}
return McpServerTool.Create(handler, options);
}
}
@@ -6,6 +6,7 @@ using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Tools.Shell.UnitTests;
@@ -226,6 +227,8 @@ public sealed class ShellEnvironmentProviderTests
public override Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public override Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default) =>
Task.FromResult(this.Responses.Dequeue());
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
throw new NotSupportedException();
public override ValueTask DisposeAsync() => default;
}
@@ -280,6 +283,8 @@ public sealed class ShellEnvironmentProviderTests
public override Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public override Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default) =>
Task.FromResult(this._factory(cancellationToken));
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
throw new NotSupportedException();
public override ValueTask DisposeAsync() => default;
}
@@ -372,6 +377,8 @@ public sealed class ShellEnvironmentProviderTests
this.RunCount++;
return Task.FromResult(this.NextResult);
}
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
throw new NotSupportedException();
public override ValueTask DisposeAsync() => default;
}
}
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
/// <summary>
/// Tests for <see cref="AgentProviderExtensions.InvokeAgentAsync"/>.
/// </summary>
public sealed class AgentProviderExtensionsTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
private const string WorkflowConversationId = "workflow-conv-id";
private const string AgentName = "test-agent";
[Fact]
public Task AutoSendFalseOnWorkflowConversationSuppressesResponseEventsAsync() =>
this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: false);
[Fact]
public Task AutoSendTrueOnWorkflowConversationEmitsResponseEventsAsync() =>
this.RunAsync(autoSend: true, conversationId: WorkflowConversationId, expectResponseEvents: true);
[Fact]
public Task AutoSendFalseOnExternalConversationSuppressesResponseEventsAsync() =>
this.RunAsync(autoSend: false, conversationId: "other-conv-id", expectResponseEvents: false);
[Fact]
public Task AutoSendTrueOnExternalConversationEmitsResponseEventsAndCopiesMessagesAsync() =>
this.RunAsync(
autoSend: true,
conversationId: "other-conv-id",
expectResponseEvents: true,
expectCrossConversationCopy: true);
private async Task RunAsync(
bool autoSend,
string conversationId,
bool expectResponseEvents,
bool expectCrossConversationCopy = false)
{
// Arrange: seed the workflow conversation id so IsWorkflowConversation can recognize it.
this.State.Set(
SystemScope.Names.ConversationId,
FormulaValue.New(WorkflowConversationId),
VariableScopeNames.System);
MockAgentProvider mockProvider = new();
AgentResponseUpdate[] updates =
[
new(ChatRole.Assistant, "hello "),
new(ChatRole.Assistant, "world"),
];
mockProvider
.Setup(p => p.InvokeAgentAsync(
AgentName,
It.IsAny<string?>(),
It.IsAny<string?>(),
It.IsAny<IEnumerable<ChatMessage>?>(),
It.IsAny<IDictionary<string, object?>?>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(updates));
List<(string ConversationId, ChatMessage Message)> copiedMessages = [];
mockProvider
.Setup(p => p.CreateMessageAsync(
It.IsAny<string>(),
It.IsAny<ChatMessage>(),
It.IsAny<CancellationToken>()))
.Returns<string, ChatMessage, CancellationToken>(
(convId, msg, _) =>
{
copiedMessages.Add((convId, msg));
return Task.FromResult(msg);
});
string actionId = this.CreateActionId().Value;
// Act
WorkflowEvent[] events =
await this.ExecuteAsync(
actionId,
async (IWorkflowContext context, ActionExecutorResult _, CancellationToken cancellationToken) =>
{
await mockProvider.Object.InvokeAgentAsync(
actionId,
context,
AgentName,
conversationId,
autoSend,
cancellationToken: cancellationToken).ConfigureAwait(false);
});
// Assert
int updateEventCount = events.OfType<AgentResponseUpdateEvent>().Count();
int responseEventCount = events.OfType<AgentResponseEvent>().Count();
if (expectResponseEvents)
{
Assert.Equal(updates.Length, updateEventCount);
Assert.Equal(1, responseEventCount);
}
else
{
Assert.Equal(0, updateEventCount);
Assert.Equal(0, responseEventCount);
}
if (expectCrossConversationCopy)
{
Assert.NotEmpty(copiedMessages);
Assert.All(copiedMessages, c => Assert.Equal(WorkflowConversationId, c.ConversationId));
}
else
{
Assert.Empty(copiedMessages);
}
}
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(IEnumerable<AgentResponseUpdate> updates)
{
foreach (AgentResponseUpdate update in updates)
{
yield return update;
}
await Task.CompletedTask;
}
}
+18 -1
View File
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.6.0] - 2026-05-21
### Added
- **agent-framework-core**: Shell tool with support for local and Docker execution ([#5664](https://github.com/microsoft/agent-framework/pull/5664))
- **agent-framework-monty**: New Monty-backed CodeAct provider package ([#5915](https://github.com/microsoft/agent-framework/pull/5915))
- **agent-framework-foundry**: Add experimental hosted tool factories on `FoundryChatClient` ([#5958](https://github.com/microsoft/agent-framework/pull/5958))
- **agent-framework-foundry**: Include tool definitions for Foundry agent evals ([#5974](https://github.com/microsoft/agent-framework/pull/5974))
- **agent-framework-a2a**: Use non-streaming transport and `return_immediately` for background ops ([#5963](https://github.com/microsoft/agent-framework/pull/5963))
### Changed
- **agent-framework-core**, **agent-framework-foundry**: [BREAKING] Enable instrumentation by default ([#5865](https://github.com/microsoft/agent-framework/pull/5865))
- **agent-framework-foundry**: Show more authentication methods in Foundry Toolbox MCP ([#5719](https://github.com/microsoft/agent-framework/pull/5719))
### Fixed
- **agent-framework-core**: Skip MCP prompt loading when unsupported ([#5370](https://github.com/microsoft/agent-framework/pull/5370))
## [1.5.0] - 2026-05-19
### Added
@@ -1088,7 +1104,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...HEAD
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
[1.3.0]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...python-1.3.0
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"a2a-sdk>=1.0.0,<2",
]
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0rc2"
version = "1.0.0rc3"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"ag-ui-protocol>=0.1.16,<0.2",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<1"
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260519"
version = "1.0.0a260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-foundry>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"agent-framework-foundry>=1.6.0,<2",
"azure-ai-contentunderstanding>=1.0.1,<1.1",
"aiohttp>=3.9,<4",
"filetype>=1.2,<2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,8 +22,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-durabletask>=1.0.0b260519,<2",
"agent-framework-core>=1.6.0,<2",
"agent-framework-durabletask>=1.0.0b260521,<2",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"openai-chatkit>=1.4.1,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
@@ -2,10 +2,14 @@
from __future__ import annotations
import abc
import asyncio.coroutines
import contextlib
import functools
import inspect
import os
import sys
import typing
import warnings
from collections.abc import Callable
from enum import Enum
@@ -75,6 +79,51 @@ class ExperimentalWarning(FeatureStageWarning):
"""Warning emitted when an experimental API is used."""
# Sentinel attribute used to detect (and reuse) a formatter we've already
# installed. This lets the install be idempotent across re-imports / reloads
# and keeps a stable reference to the previous formatter for testing or
# external restoration via ``warnings.formatwarning = original``.
_FEATURE_STAGE_FORMATTER_MARKER = "__feature_stage_formatter__"
def _install_feature_stage_formatter() -> None:
"""Install a single-line formatter for FeatureStageWarning categories.
The stdlib default formatter emits two lines (header + source snippet)
which is noisy for our warnings — the offending class/function name is
already in the message, so a one-line ``file:lineno: Category: message``
is enough. Other warning categories are delegated to the previous
formatter so we never change behaviour for unrelated warnings.
The install is idempotent: if a formatter installed by this module is
already in place, we leave it alone so re-imports (and any third-party
formatter wrapped on top of ours) don't get wrapped multiple times.
"""
current = warnings.formatwarning
if getattr(current, _FEATURE_STAGE_FORMATTER_MARKER, False):
return
def _formatwarning(
message: Warning | str,
category: type[Warning],
filename: str,
lineno: int,
line: str | None = None,
) -> str:
if issubclass(category, FeatureStageWarning):
return f"{filename}:{lineno}: {category.__name__}: {message}\n"
return current(message, category, filename, lineno, line)
setattr(_formatwarning, _FEATURE_STAGE_FORMATTER_MARKER, True)
# Keep a reference to the wrapped formatter so callers (tests, embedders)
# can restore the previous behaviour if they need to.
_formatwarning.__wrapped__ = current # type: ignore[attr-defined]
warnings.formatwarning = _formatwarning
_install_feature_stage_formatter()
def _normalize_feature_id(feature_id: str | Enum) -> str:
return str(feature_id.value if isinstance(feature_id, Enum) else feature_id)
@@ -109,23 +158,91 @@ def _set_feature_stage_metadata(obj: Any, *, stage: FeatureStageName, feature_id
setattr(obj, _FEATURE_ID_ATTR, feature_id)
_INTERNAL_FRAME_FILE = os.path.normcase(__file__)
# Module names whose frames we never want to surface as the caller. ``abc`` is
# the big one (its ``__new__`` shows up as ``<frozen abc>:106`` for ABC-driven
# subclass creation on modern CPython, so we cannot rely on filename matching).
# ``functools``/``typing``/``contextlib`` are added because they often wrap our
# decorators or appear in the metaclass call path.
_INTERNAL_FRAME_MODULES: frozenset[str] = frozenset({
abc.__name__,
functools.__name__,
typing.__name__,
contextlib.__name__,
})
def _is_internal_frame(frame: Any) -> bool:
if os.path.normcase(frame.f_code.co_filename) == _INTERNAL_FRAME_FILE:
return True
module_name = frame.f_globals.get("__name__", "")
if module_name in _INTERNAL_FRAME_MODULES:
return True
# Submodules of the skipped stdlib packages (``typing.ext``, ``functools``
# wrappers under ``concurrent.futures._base``, etc.) are also wrappers we
# don't want to surface.
return any(module_name.startswith(prefix + ".") for prefix in _INTERNAL_FRAME_MODULES)
def _resolve_user_frame() -> tuple[str, int, str] | None:
"""Resolve the user frame that triggered an experimental warning.
Walk the stack and return ``(filename, lineno, module_name)`` for the first
frame outside this module and the wrapping/metaclass machinery.
Returns ``None`` if no such frame is found; callers fall back to plain
``warnings.warn`` with a fixed stacklevel.
"""
# Frame objects participate in reference cycles (``frame -> f_locals ->
# frame``) and can delay GC if held implicitly. Capture the user frame's
# data into plain values inside the try, and explicitly delete the frame
# references in finally so we never leak frames across this call. This
# follows CPython's own guidance for code that uses ``inspect.currentframe``.
frame = inspect.currentframe()
candidate: Any = None
try:
if frame is None:
return None
# Skip _resolve_user_frame itself + the warn helper that called it.
candidate = frame.f_back.f_back if frame.f_back and frame.f_back.f_back else None
while candidate is not None:
if not _is_internal_frame(candidate):
return (
candidate.f_code.co_filename,
candidate.f_lineno,
candidate.f_globals.get("__name__", "<unknown>"),
)
candidate = candidate.f_back
return None
finally:
del frame, candidate
def _warn_on_feature_use(
*,
stage: FeatureStageName,
feature_id: str,
object_name: str,
category: type[Warning],
stacklevel: int,
) -> None:
warning_key = (category, feature_id)
if warning_key in _WARNED_FEATURES:
return
warnings.warn(
_build_stage_warning_message(stage=stage, feature_id=feature_id, object_name=object_name),
category=category,
stacklevel=stacklevel,
)
message = _build_stage_warning_message(stage=stage, feature_id=feature_id, object_name=object_name)
user_frame = _resolve_user_frame()
if user_frame is None:
# Last-resort fallback: emit at the immediate caller of this helper.
warnings.warn(message, category=category, stacklevel=2)
else:
filename, lineno, module = user_frame
warnings.warn_explicit(
message,
category=category,
filename=filename,
lineno=lineno,
module=module,
)
_WARNED_FEATURES.add(warning_key)
@@ -150,7 +267,6 @@ def _add_runtime_warning(
feature_id=feature_id,
object_name=object_name,
category=category,
stacklevel=3,
)
if original_new is not object.__new__:
return original_new(cls, *args, **kwargs)
@@ -171,7 +287,6 @@ def _add_runtime_warning(
feature_id=feature_id,
object_name=object_name,
category=category,
stacklevel=3,
)
return original_init_subclass_func(*args, **kwargs)
@@ -185,7 +300,6 @@ def _add_runtime_warning(
feature_id=feature_id,
object_name=object_name,
category=category,
stacklevel=3,
)
return original_init_subclass(*args, **kwargs)
@@ -200,7 +314,6 @@ def _add_runtime_warning(
feature_id=feature_id,
object_name=object_name,
category=category,
stacklevel=3,
)
return obj(*args, **kwargs)
@@ -2153,15 +2153,14 @@ def _capture_messages(
finish_reason: FinishReason | None = None,
) -> None:
"""Log messages with extra information."""
from ._types import normalize_messages, prepend_instructions_to_messages
from ._types import normalize_messages
prepped = prepend_instructions_to_messages(normalize_messages(messages), system_instructions)
normalized_messages = normalize_messages(messages)
otel_messages: list[dict[str, Any]] = []
for index, message in enumerate(prepped):
for index, message in enumerate(normalized_messages):
# Reuse the otel message representation for logging instead of calling to_dict()
# to avoid expensive Pydantic serialization overhead
otel_message = _to_otel_message(message)
otel_messages.append(otel_message)
logger.info(
otel_message,
extra={
@@ -2170,6 +2169,7 @@ def _capture_messages(
MessageListTimestampFilter.INDEX_KEY: index,
},
)
otel_messages.append(otel_message)
if finish_reason:
otel_messages[-1]["finish_reason"] = FINISH_REASON_MAP[finish_reason]
span.set_attribute(
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.5.0"
version = "1.6.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -142,6 +142,39 @@ def test_experimental_class_warns_on_instantiation_and_not_on_definition() -> No
assert ExperimentalClass.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
def test_experimental_abc_subclass_warning_points_at_user_file() -> None:
"""Subclassing an experimental ABC must report the warning at the user's
``class Sub(...):`` line, not at internal abc.py / <frozen abc> frames.
Regression: previously the fixed ``stacklevel=3`` landed inside abc.py for
ABC-driven class creation, surfacing ``<frozen abc>:106`` to users.
"""
from abc import ABC, abstractmethod
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
class ExperimentalABC(ABC):
@abstractmethod
def do(self) -> int: ...
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
subclass_line = inspect.currentframe().f_lineno + 1
class Concrete(ExperimentalABC):
def do(self) -> int:
return 1
assert len(caught) == 1
assert caught[0].filename == __file__
# __init_subclass__ fires at the end of the class body, so the lineno
# points somewhere inside the Concrete class definition rather than at
# the ``class Concrete`` header itself. The key behaviour we want to
# guarantee is that it is in the *user* file at all (not abc.py).
assert subclass_line <= caught[0].lineno <= subclass_line + 5
assert issubclass(caught[0].category, ExperimentalWarning)
assert Concrete().do() == 1
def test_experimental_runtime_checkable_protocol_keeps_protocol_runtime_checks() -> None:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
@@ -290,9 +290,9 @@ async def test_chat_client_observability_with_instructions(
assert len(system_instructions) == 1
assert system_instructions[0]["content"] == "You are a helpful assistant."
# Verify input_messages contains system message
# Verify input_messages excludes system instructions
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
assert any(msg.get("role") == "system" for msg in input_messages)
assert [msg.get("role") for msg in input_messages] == ["user"]
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
@@ -324,6 +324,40 @@ async def test_chat_client_streaming_observability_with_instructions(
assert len(system_instructions) == 1
assert system_instructions[0]["content"] == "You are a helpful assistant."
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
assert [msg.get("role") for msg in input_messages] == ["user"]
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_chat_client_observability_with_system_message_and_instructions(
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Test input chat-history system messages stay in input_messages when instructions are separate."""
import json
client = mock_chat_client()
messages = [
Message(role="system", contents=["Original system message"]),
Message(role="user", contents=["Test message"]),
]
options = {"model": "Test", "instructions": "Framework system instruction"}
span_exporter.clear()
response = await client.get_response(messages=messages, options=options)
assert response is not None
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
system_instructions = json.loads(span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS])
assert system_instructions == [{"type": "text", "content": "Framework system instruction"}]
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
assert [msg.get("role") for msg in input_messages] == ["system", "user"]
assert input_messages[0]["parts"][0]["content"] == "Original system message"
assert input_messages[1]["parts"][0]["content"] == "Test message"
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_chat_client_observability_without_instructions(
@@ -2981,6 +3015,81 @@ async def test_system_instructions_preserves_non_ascii_characters(span_exporter:
system_instructions = json.loads(system_instructions_json)
assert system_instructions[0]["content"] == chinese_text
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
assert [msg.get("role") for msg in input_messages] == ["user"]
def test_capture_messages_keeps_framework_instructions_out_of_logs_and_span_messages(
span_exporter: InMemorySpanExporter,
):
"""Test separate framework instructions do not appear in chat-history logs or span messages."""
import json
from opentelemetry import trace
tracer = trace.get_tracer("test")
span_exporter.clear()
with (
patch("agent_framework.observability.logger.info") as mock_logger_info,
tracer.start_as_current_span("test_span") as span,
):
_capture_messages(
span=span,
provider_name="test_provider",
messages=[Message(role="user", contents=["Test"])],
system_instructions="Framework system instruction",
)
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
input_messages = json.loads(spans[0].attributes[OtelAttr.INPUT_MESSAGES])
assert [msg.get("role") for msg in input_messages] == ["user"]
assert mock_logger_info.call_count == 1, f"Expected 1 log call, got {mock_logger_info.call_count}"
(first_call,) = mock_logger_info.call_args_list
assert first_call.args
logged_message = first_call.args[0]
assert logged_message["role"] == "user"
assert logged_message["parts"][0]["content"] == "Test"
def test_capture_messages_logs_only_chat_history_when_framework_instructions_are_separate(
span_exporter: InMemorySpanExporter,
):
"""Test chat-history logging preserves original system messages without prepending framework instructions."""
import json
from opentelemetry import trace
tracer = trace.get_tracer("test")
span_exporter.clear()
with (
patch("agent_framework.observability.logger.info") as mock_logger_info,
tracer.start_as_current_span("test_span") as span,
):
_capture_messages(
span=span,
provider_name="test_provider",
messages=[
Message(role="system", contents=["Original system message"]),
Message(role="user", contents=["Test"]),
],
system_instructions="Framework system instruction",
)
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
input_messages = json.loads(spans[0].attributes[OtelAttr.INPUT_MESSAGES])
assert [msg.get("role") for msg in input_messages] == ["system", "user"]
assert mock_logger_info.call_count == 2, f"Expected 2 log calls, got {mock_logger_info.call_count}"
logged_messages = [call.args[0] for call in mock_logger_info.call_args_list]
assert [msg["role"] for msg in logged_messages] == ["system", "user"]
assert logged_messages[0]["parts"][0]["content"] == "Original system message"
assert logged_messages[1]["parts"][0]["content"] == "Test"
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_tool_arguments_preserves_non_ascii_characters(span_exporter: InMemorySpanExporter):
@@ -3104,6 +3213,40 @@ async def test_agent_instructions_from_default_options(
assert len(system_instructions) == 1
assert system_instructions[0]["content"] == "Default system instructions."
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
assert [msg.get("role") for msg in input_messages] == ["user"]
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_agent_instructions_preserve_system_messages_in_history(
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Test agent spans keep chat-history system messages separate from framework instructions."""
import json
agent = mock_chat_agent()
agent.default_options = {"model": "TestModel", "instructions": "Default system instructions."}
messages = [
Message(role="system", contents=["Original system message"]),
Message(role="user", contents=["Test message"]),
]
span_exporter.clear()
response = await agent.run(messages)
assert response is not None
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
system_instructions = json.loads(span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS])
assert system_instructions == [{"type": "text", "content": "Default system instructions."}]
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
assert [msg.get("role") for msg in input_messages] == ["system", "user"]
assert input_messages[0]["parts"][0]["content"] == "Original system message"
assert input_messages[1]["parts"][0]["content"] == "Test message"
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_agent_instructions_from_options_override(
@@ -817,10 +817,14 @@ class DeclarativeWorkflowBuilder:
condition=lambda msg: isinstance(msg, LoopIterationResult) and msg.has_next,
)
# Body exit -> Next (get all exits from body and wire to next_executor)
body_exits = self._get_source_exits(body_entry)
for body_exit in body_exits:
builder.add_edge(source=body_exit, target=next_executor)
# Wire from the LAST body action so the loop only advances after the
# whole body completes. _get_branch_exit walks the chain, skips
# terminators (Break/Continue), and returns nested If/Switch
# structures so _get_source_exits can flatten their branch exits.
body_exit = self._get_branch_exit(body_entry)
if body_exit is not None:
for source_exit in self._get_source_exits(body_exit):
builder.add_edge(source=source_exit, target=next_executor)
# Next -> body (when has_next=True, loop back)
builder.add_edge(
@@ -1008,16 +1012,12 @@ class DeclarativeWorkflowBuilder:
return entry.evaluator if is_structure else entry
def _get_branch_exit(self, branch_entry: Any) -> Any | None:
"""Get the exit executor of a branch.
"""Get the exit point of a branch for downstream wiring.
For a linear sequence of actions, returns the last executor.
For nested structures, returns None (they have their own branch_exits).
Args:
branch_entry: The first executor of the branch
Returns:
The exit executor, or None if branch is empty or ends with a structure
Returns the last executor (or its ``_exit_executor``) for a linear chain,
the nested If/Switch structure itself when the chain ends in one (so
callers can flatten ``branch_exits`` via :meth:`_get_source_exits`), or
``None`` when the branch is empty or ends in a terminator action.
"""
if branch_entry is None:
return None
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"httpx>=0.27,<1",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
@@ -2224,6 +2224,101 @@ class TestBuilderControlFlowCreation:
class TestBuilderEdgeWiring:
"""Tests for builder edge wiring methods."""
def test_foreach_advance_edge_wired_from_last_body_action(self):
"""Advance edge must come from the last body action."""
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
yaml_def = {
"name": "foreach_seq",
"actions": [
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
{"kind": "SendActivity", "id": "step_2", "activity": {"text": "two"}},
{"kind": "SendActivity", "id": "step_3", "activity": {"text": "three"}},
],
},
],
}
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
edges = {(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges}
assert ("step_3", "loop_next") in edges
assert ("step_1", "loop_next") not in edges
assert ("step_2", "loop_next") not in edges
assert ("step_1", "step_2") in edges
assert ("step_2", "step_3") in edges
def test_foreach_advance_edge_skipped_for_terminator_body(self):
"""BreakLoop at end of body wires itself to loop_next; no duplicate edge."""
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
yaml_def = {
"name": "foreach_terminator",
"actions": [
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A"]},
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
{"kind": "BreakLoop", "id": "stop"},
],
},
],
}
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
all_edges = [(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges]
assert all_edges.count(("stop", "loop_next")) == 1
assert ("step_1", "loop_next") not in all_edges
def test_foreach_advance_edge_with_if_as_last_body_action(self):
"""Trailing If in a Foreach body wires every branch exit to loop_next."""
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
yaml_def = {
"name": "foreach_if_last",
"actions": [
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
{
"kind": "If",
"id": "check",
"condition": '=Local.item = "A"',
"then": [
{"kind": "SendActivity", "id": "then_action", "activity": {"text": "then"}},
],
"else": [
{"kind": "SendActivity", "id": "else_action", "activity": {"text": "else"}},
],
},
],
},
],
}
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
edges = {(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges}
assert ("then_action", "loop_next") in edges
assert ("else_action", "loop_next") in edges
assert ("step_1", "loop_next") not in edges
def test_wire_to_target_with_if_structure(self):
"""Test wiring to an If structure routes to evaluator."""
from agent_framework import WorkflowBuilder
@@ -121,6 +121,35 @@ class TestGraphBasedWorkflowExecution:
assert "b" in outputs
assert "c" in outputs
@pytest.mark.asyncio
async def test_foreach_multi_action_body_runs_sequentially(self):
"""Body actions must complete per item before advancing."""
yaml_def = {
"name": "loop_sequential_body",
"actions": [
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": '="1-" & Local.item'}},
{"kind": "SendActivity", "id": "step_2", "activity": {"text": '="2-" & Local.item'}},
{"kind": "SendActivity", "id": "step_3", "activity": {"text": '="3-" & Local.item'}},
],
},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
events = await workflow.run(ActionTrigger())
outputs = events.get_outputs()
assert outputs == ["1-A", "2-A", "3-A", "1-B", "2-B", "3-B"]
@pytest.mark.asyncio
async def test_workflow_with_switch(self):
"""Test workflow with Switch/ConditionGroup."""
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"openai>=1.99.0,<3",
"opentelemetry-sdk>=1.39.0,<2",
"fastapi>=0.115.0,<0.133.1",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"durabletask>=1.4.0,!=1.4.1,!=1.4.2,!=1.4.3,<2",
"durabletask-azuremanaged>=1.4.0,<2",
"python-dateutil>=2.8.0,<3",
@@ -75,6 +75,15 @@ _TOOL_EVALUATORS: set[str] = {
"builtin.tool_call_success",
}
# Evaluators that accept tool_definitions in their data mapping when the
# evaluated items include tools.
_TOOL_DEFINITION_EVALUATORS: set[str] = _TOOL_EVALUATORS | {
"builtin.intent_resolution",
"builtin.task_adherence",
"builtin.task_completion",
"builtin.task_navigation_efficiency",
}
# Evaluators that require a ground_truth / expected_output field.
_GROUND_TRUTH_EVALUATORS: set[str] = {
"builtin.similarity",
@@ -161,6 +170,7 @@ def _build_testing_criteria(
model: str,
*,
include_data_mapping: bool = False,
include_tool_definitions: bool = False,
) -> list[dict[str, Any]]:
"""Build ``testing_criteria`` for ``evals.create()``.
@@ -169,6 +179,8 @@ def _build_testing_criteria(
model: Model deployment for the LLM judge.
include_data_mapping: Whether to include field-level data mapping
(required for the JSONL data source, not needed for response-based).
include_tool_definitions: Whether the mapped data items include tool
definitions.
"""
criteria: list[dict[str, Any]] = []
for name in evaluators:
@@ -203,7 +215,7 @@ def _build_testing_criteria(
mapping["context"] = "{{item.context}}"
if qualified in _GROUND_TRUTH_EVALUATORS:
mapping["ground_truth"] = "{{item.ground_truth}}"
if qualified in _TOOL_EVALUATORS:
if include_tool_definitions and qualified in _TOOL_DEFINITION_EVALUATORS:
mapping["tool_definitions"] = "{{item.tool_definitions}}"
entry["data_mapping"] = mapping
@@ -713,6 +725,7 @@ class FoundryEvals:
evaluators,
self._model,
include_data_mapping=True,
include_tool_definitions=has_tools,
),
)
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.5.0"
version = "1.6.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-openai>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"agent-framework-openai>=1.6.0,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.1.0,<3.0",
]
@@ -900,7 +900,7 @@ async def test_integration_web_search() -> None:
"messages": [
Message(
role="user",
contents=["Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer."],
contents=["Where is Microsoft's headquarters? Do a web search to find the answer."],
)
],
"options": {"tool_choice": "auto", "tools": [web_search_tool]},
@@ -908,9 +908,7 @@ async def test_integration_web_search() -> None:
response = await client.get_response(stream=True, **content).get_final_response()
assert isinstance(response, ChatResponse)
assert "Rumi" in response.text
assert "Mira" in response.text
assert "Zoey" in response.text
assert "redmond" in response.text.lower()
@pytest.mark.flaky
@@ -745,7 +745,12 @@ class TestBuildTestingCriteria:
assert "conversation" not in criteria[1]["data_mapping"]
def test_tool_evaluator_includes_tool_definitions(self) -> None:
criteria = _build_testing_criteria(["relevance", "tool_call_accuracy"], "gpt-4o", include_data_mapping=True)
criteria = _build_testing_criteria(
["relevance", "tool_call_accuracy"],
"gpt-4o",
include_data_mapping=True,
include_tool_definitions=True,
)
# relevance: string query/response
assert criteria[0]["data_mapping"]["query"] == "{{item.query}}"
assert criteria[0]["data_mapping"]["response"] == "{{item.response}}"
@@ -762,6 +767,17 @@ class TestBuildTestingCriteria:
assert c["data_mapping"]["query"] == "{{item.query_messages}}", f"{c['name']}"
assert c["data_mapping"]["response"] == "{{item.response_messages}}", f"{c['name']}"
def test_agent_evaluators_include_tool_definitions_when_tools_present(self) -> None:
agent_evals = ["task_adherence", "intent_resolution", "task_completion", "task_navigation_efficiency"]
criteria = _build_testing_criteria(
agent_evals,
"gpt-4o",
include_data_mapping=True,
include_tool_definitions=True,
)
for c in criteria:
assert c["data_mapping"]["tool_definitions"] == "{{item.tool_definitions}}", f"{c['name']}"
def test_quality_evaluators_use_strings(self) -> None:
quality_evals = ["coherence", "relevance", "fluency"]
criteria = _build_testing_criteria(quality_evals, "gpt-4o", include_data_mapping=True)
@@ -781,7 +797,12 @@ class TestBuildTestingCriteria:
"tool_output_utilization",
"tool_call_success",
]
criteria = _build_testing_criteria(tool_evals, "gpt-4o", include_data_mapping=True)
criteria = _build_testing_criteria(
tool_evals,
"gpt-4o",
include_data_mapping=True,
include_tool_definitions=True,
)
for c in criteria:
assert "tool_definitions" in c["data_mapping"], f"{c['name']} missing tool_definitions"
@@ -9,8 +9,9 @@ import logging
import os
import tempfile
import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from collections.abc import AsyncIterable, AsyncIterator, Generator, Sequence
from contextlib import suppress
from dataclasses import asdict, is_dataclass
from pathlib import Path
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
from typing import Protocol, cast
@@ -1505,11 +1506,20 @@ def _convert_message_content(content: MessageContent) -> Content:
# region Output Item Conversion
def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
def _argument_json_default(value: Any) -> Any:
if is_dataclass(value) and not isinstance(value, type):
return asdict(value)
to_dict = getattr(value, "to_dict", None)
if callable(to_dict):
return to_dict()
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
def _arguments_to_str(arguments: Any | None) -> str:
"""Convert arguments to a JSON string.
Args:
arguments: The arguments to convert, can be a string, mapping, or None.
arguments: The arguments to convert, can be a string, JSON-like object, or None.
Returns:
The arguments as a JSON string.
@@ -1518,7 +1528,7 @@ def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
return ""
if isinstance(arguments, str):
return arguments
return json.dumps(arguments)
return json.dumps(arguments, default=_argument_json_default)
async def _to_outputs(
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260519"
version = "1.0.0a260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"azure-ai-agentserver-core>=2.0.0b3,<3",
"azure-ai-agentserver-responses>=1.0.0b5,<2",
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
@@ -12,6 +12,7 @@ from __future__ import annotations
import json
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock
import httpx
@@ -405,6 +406,36 @@ class TestStreaming:
assert len(args_done) == 1
assert args_done[0]["data"]["arguments"] == '{"q": "hello"}'
async def test_function_call_streaming_serializes_dataclass_arguments(self) -> None:
@dataclass
class HandoffLikeRequest:
agent_response: AgentResponse
request = HandoffLikeRequest(
agent_response=AgentResponse(
messages=[Message(role="assistant", contents=[Content.from_text("Need more details")])]
)
)
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "handoff_to_refund", arguments=request)],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
assert len(args_done) == 1
payload = json.loads(args_done[0]["data"]["arguments"])
assert payload["agent_response"]["type"] == "agent_response"
assert payload["agent_response"]["messages"][0]["contents"][0]["text"] == "Need more details"
async def test_alternating_text_and_function_call(self) -> None:
agent = _make_agent(
stream_updates=[
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-openai>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"agent-framework-openai>=1.6.0,<2",
"foundry-local-sdk>=0.5.1,<0.5.2",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260519"
version = "1.0.0a260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2.0",
"agent-framework-core>=1.6.0,<2.0",
"google-genai>=1.65.0,<2.0.0",
]
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"hyperlight-sandbox>=0.4.0,<0.5",
"hyperlight-sandbox-backend-wasm>=0.4.0,<0.5 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
"hyperlight-sandbox-python-guest>=0.4.0,<0.5",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
]
[project.optional-dependencies]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"mem0ai>=1.0.0,<2",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Monty CodeAct integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260518"
version = "1.0.0a260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"agent-framework-core>=1.6.0,<2",
"pydantic-monty>=0,<0.1",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"ollama>=0.5.3,<0.5.4",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.5.0"
version = "1.6.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"openai>=1.99.0,<3",
]
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0rc1"
version = "1.0.0rc2"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
]
[tool.uv]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"azure-core>=1.30.0,<2",
"httpx>=0.27.0,<0.29",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260521"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.6.0,<2",
"redis>=6.4.0,<7.2.1",
"redisvl>=0.11.0,<0.16",
"numpy>=2.2.6,<3"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+171
View File
@@ -0,0 +1,171 @@
# agent-framework-tools
Alpha built-in tools for the Microsoft Agent Framework. A home for first-party
Python tools that plug into any chat client's shell / function surface. The
first tool is `LocalShellTool`.
## Installation
```bash
pip install agent-framework-tools --pre
```
## `LocalShellTool` quick start
```python
import asyncio
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
from agent_framework_tools.shell import LocalShellTool
async def main() -> None:
client = OpenAIChatClient(model="gpt-5.4-nano")
async with LocalShellTool() as shell:
agent = Agent(
client=client,
instructions="You are a helpful assistant that can run shell commands.",
tools=[client.get_shell_tool(func=shell.as_function())],
)
result = await agent.run("Print the current working directory.")
print(result.text)
asyncio.run(main())
```
### Modes
- **Persistent** (default): a single long-lived shell session. `cd`, `export`,
and shell functions persist across tool invocations.
- **Stateless** (`mode="stateless"`): each command runs in a fresh subprocess.
### Safety
> **`LocalShellTool` is not a sandbox.** It runs commands directly on the
> host with the agent process's privileges. The actual security boundary
> is **approval-in-the-loop**. For untrusted input use a sandboxed
> executor — see [`agent-framework-hyperlight`](#relationship-to-agent-framework-hyperlight).
Defenses (in priority order):
- **Approval-in-the-loop** — every command surfaces as a
`user_input_request`; nothing runs without consent. Disabling this
requires `acknowledge_unsafe=True`.
- **Process-tree termination on timeout** via `psutil`, so child
processes (`make`, watchers, network tools) cannot survive the timeout.
- **Output truncation** to 64 KiB (head + tail with marker).
- **Audit hook** (`on_command=…`) for SIEM / append-only logs.
- **Optional command-pattern filter** via `ShellPolicy(denylist=[...],
allowlist=[...])`. **Empty by default.** This is a UX pre-filter, not a
security boundary — operators are expected to supply patterns that
match their workload (and they can be defeated by trivial obfuscation
such as `\rm -rf /`, `${RM:=rm} -rf /`, `python -c "…"`, encoded
payloads, or PowerShell-native equivalents). Real isolation comes from
approval gating and the sandbox tier (`DockerShellTool`). See
`tests/test_security.py` for the documented residual risk surface.
Override with `ShellPolicy`:
```python
from agent_framework_tools.shell import LocalShellTool, ShellPolicy
shell = LocalShellTool(
policy=ShellPolicy(allowlist=[r"^ls\b", r"^cat\b", r"^git status$"]),
approval_mode="never_require",
acknowledge_unsafe=True, # required to bypass approval
)
```
### Cross-OS
- **Windows**: `pwsh -NoProfile -Command -` (falls back to `powershell.exe`).
- **Linux / macOS**: `/bin/bash --noprofile --norc` (falls back to `/bin/sh`).
- Override via the `shell=` constructor argument or the
`AGENT_FRAMEWORK_SHELL` environment variable.
## `ShellEnvironmentProvider` — context provider
A model talking to a PowerShell session will sometimes default to bash
syntax (`export FOO=bar`, `ls -la`, `> /dev/null`) and vice versa.
`ShellEnvironmentProvider` is an `AIContextProvider` that probes the live
shell once per session — family, version, OS, working directory, and a
configurable list of CLI tools (`git`, `node`, `python`, `docker` by
default) — and injects a system-prompt block describing the shell idiom
to use and the available CLIs.
```python
from agent_framework_tools.shell import (
LocalShellTool,
ShellEnvironmentProvider,
ShellEnvironmentProviderOptions,
)
shell = LocalShellTool()
provider = ShellEnvironmentProvider(
shell,
ShellEnvironmentProviderOptions(probe_tools=("git", "uv", "node")),
)
agent = Agent(
client=client,
tools=[client.get_shell_tool(func=shell.as_function())],
context_providers=[provider],
)
```
Probe failures from expected error types (timeouts, policy rejections,
spawn failures) are recorded as `None` fields in the snapshot rather
than raised; a missing CLI never fails the agent. A failed first probe
does not poison the cache — the next call retries.
## `DockerShellTool` — sandboxed tier
When commands originate from untrusted input (e.g. the model is acting on
prompt-injected document content), prefer `DockerShellTool`. With the
default isolation flags and a trusted container runtime, the container
is the intended security boundary and approval gating becomes optional.
```python
import asyncio
from agent_framework_tools.shell import DockerShellTool
async def main() -> None:
async with DockerShellTool(
image="mcr.microsoft.com/azurelinux/base/core:3.0",
approval_mode="never_require", # container is the boundary
) as shell:
result = await shell.run("uname -a && id")
print(result.stdout)
asyncio.run(main())
```
Defaults applied to every container:
- `--network none` — no host or external network.
- `--user 65534:65534` — runs as `nobody:nogroup`.
- `--read-only` root filesystem; only mounted host paths are writable.
- `--cap-drop ALL` and `--security-opt no-new-privileges`.
- `--memory 512m`, `--pids-limit 256`, ephemeral `tmpfs /tmp`.
To expose a host directory, pass `host_workdir="/path"` (mounted
read-only by default; `mount_readonly=False` to allow writes). Swap the
container runtime with `docker_binary="podman"`.
## Sandbox tiers at a glance
| Use case | Tool | Sandbox |
|---|---|---|
| Run *code* (untrusted) | `HyperlightCodeActProvider.execute_code` (`agent-framework-hyperlight`) | Hyperlight WASM microVM |
| Run *shell* (untrusted) | `DockerShellTool` | OCI container (network-off, non-root, capabilities dropped) |
| Run *shell* (trusted dev) | `LocalShellTool` | Approval-in-the-loop |
## Relationship to `agent-framework-hyperlight`
`agent-framework-hyperlight` is a **code** sandbox (a single WASM guest
loaded into a microVM, called via a hostcall ABI — there is no kernel,
userland, or shell binary inside). It is the right tier for executing
generated *code*. For sandboxing *shell* commands, the realistic tier is
OCI, which `DockerShellTool` provides.
@@ -0,0 +1,14 @@
# Copyright (c) Microsoft. All rights reserved.
"""Built-in tools for the Microsoft Agent Framework."""
from __future__ import annotations
import importlib.metadata
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = ["__version__"]
@@ -0,0 +1,53 @@
# Copyright (c) Microsoft. All rights reserved.
"""Cross-platform local shell tool for the Microsoft Agent Framework."""
from __future__ import annotations
from ._docker import (
DEFAULT_IMAGE as DOCKER_DEFAULT_IMAGE,
)
from ._docker import (
DockerNotAvailableError,
DockerShellTool,
is_docker_available,
)
from ._environment import (
ShellEnvironmentProvider,
ShellEnvironmentProviderOptions,
ShellEnvironmentSnapshot,
ShellFamily,
default_instructions_formatter,
)
from ._executor_base import ShellExecutor
from ._policy import ShellDecision, ShellPolicy, ShellRequest
from ._tool import LocalShellTool
from ._types import (
ShellCommandError,
ShellExecutionError,
ShellMode,
ShellResult,
ShellTimeoutError,
)
__all__ = [
"DOCKER_DEFAULT_IMAGE",
"DockerNotAvailableError",
"DockerShellTool",
"LocalShellTool",
"ShellCommandError",
"ShellDecision",
"ShellEnvironmentProvider",
"ShellEnvironmentProviderOptions",
"ShellEnvironmentSnapshot",
"ShellExecutionError",
"ShellExecutor",
"ShellFamily",
"ShellMode",
"ShellPolicy",
"ShellRequest",
"ShellResult",
"ShellTimeoutError",
"default_instructions_formatter",
"is_docker_available",
]
@@ -0,0 +1,700 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sandboxed shell tool backed by a Docker (or compatible) container runtime.
``DockerShellTool`` exposes the same public surface as
:class:`LocalShellTool` but executes commands inside a container. The
container is intended to be the security boundary; effective isolation
depends on the host runtime configuration, image contents, and the flags
passed at launch.
Default flags applied at launch:
- ``--network none``: no host or external network access.
- ``--user 65534:65534``: runs as ``nobody:nogroup``.
- ``--read-only`` root filesystem; only the optional ``host_workdir``
mount is writable when ``mount_readonly=False``.
- ``--memory``, ``--pids-limit`` set to bounded values.
- ``--cap-drop=ALL`` and ``--security-opt=no-new-privileges``.
- ``--tmpfs /tmp`` so commands that need scratch space have somewhere to
write that doesn't escape the container.
Persistent mode reuses :class:`ShellSession` by launching
``docker exec -i <container> bash`` as the long-lived shell — the
sentinel protocol works unchanged because the session is still talking
to a bash REPL over pipes.
**Single-session ownership.** In persistent mode a
:class:`DockerShellTool` owns a long-lived container plus the bash REPL
inside it. The container's filesystem, environment, working directory,
and any artifacts the agent has produced are visible to every subsequent
command, and a single stdin/stdout pipe serializes every call. A
persistent-mode tool is therefore intended to be owned by exactly one
conversation / agent session — i.e. one user. Do not share one instance
across users, tenants, or concurrent conversations: their state leaks
together inside the container and commands queue behind each other.
Create one tool per session and close it (or use ``async with``) when
the session ends; closing stops and removes the container. If a shared
instance is genuinely required, use ``mode="stateless"`` so each call
gets its own throwaway ``docker run --rm``.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import secrets
import shutil
import subprocess # noqa: S404 # nosec B404 - running shell commands is the whole point of this tool
import time
from collections.abc import Callable, Mapping, Sequence
from typing import Literal
from agent_framework import FunctionTool, tool
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
from ._policy import ShellPolicy, ShellRequest
from ._session import ShellSession
from ._truncate import truncate_head_tail as _truncate_bytes
from ._types import ShellCommandError, ShellMode, ShellResult
logger = logging.getLogger(__name__)
DEFAULT_IMAGE = "mcr.microsoft.com/azurelinux/base/core:3.0"
DEFAULT_CONTAINER_USER = "65534:65534" # nobody:nogroup on most distros
DEFAULT_NETWORK = "none"
DEFAULT_MEMORY = "512m"
DEFAULT_PIDS_LIMIT = 256
DEFAULT_WORKDIR = "/workspace"
# Docker run flags that would silently dismantle the isolation defaults
# (--cap-drop ALL, --security-opt no-new-privileges, --network none,
# --read-only root, pids/memory caps) if spliced in via ``extra_run_args``.
# These are rejected at construction time so the failure surfaces loudly
# rather than as a silently-unsandboxed container at runtime.
_BLOCKED_EXTRA_RUN_FLAGS: tuple[str, ...] = (
"--privileged",
"--cap-add",
"--security-opt",
"--network",
"--net",
"--volume",
"-v",
"--mount",
"--device",
"--device-cgroup-rule",
"--pid",
"--ipc",
"--userns",
"--user",
"--cgroupns",
"--add-host",
"--gpus",
"--read-only",
"--tmpfs",
)
def _validate_extra_run_args(args: Sequence[str]) -> None:
"""Reject extra ``docker run`` args that would break the isolation contract.
A caller can otherwise pass ``["--privileged"]``, ``["--network=host"]``,
``["-v", "/:/host:rw"]``, etc. and silently undo every isolation flag
this tool sets. The blocklist covers the obvious offenders; operators
that genuinely need one of these flags should subclass the tool or
build their own argv rather than slip past the check.
"""
bad: list[str] = []
for raw in args:
if not raw.startswith("-"):
continue
# Split off any "=value" tail so "--network=host" matches "--network".
flag = raw.split("=", 1)[0]
if flag in _BLOCKED_EXTRA_RUN_FLAGS:
bad.append(raw)
if bad:
raise ValueError(
"extra_run_args contains flags that would dismantle DockerShellTool's "
f"isolation defaults: {bad}. Override these via dedicated constructor "
"arguments (network, host_workdir, mount_readonly, read_only_root, "
"memory, pids_limit, user) or subclass the tool if you really need "
"to relax the sandbox."
)
class DockerNotAvailableError(RuntimeError):
"""Raised when the configured docker binary cannot be reached."""
def is_docker_available(binary: str = "docker") -> bool:
"""Return ``True`` if ``binary`` is on PATH and the daemon responds."""
if shutil.which(binary) is None:
return False
try:
out = subprocess.run( # noqa: S603 # nosec B603 - argv is built from trusted binary name
[binary, "version", "--format", "{{.Server.Version}}"],
capture_output=True,
timeout=5.0,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return False
return out.returncode == 0 and bool(out.stdout.strip())
# ---------------------------------------------------------------------------
# Pure argv builders. Kept side-effect-free so unit tests don't need Docker.
# ---------------------------------------------------------------------------
def build_run_argv(
*,
binary: str,
image: str,
container_name: str,
user: str,
network: str,
memory: str,
pids_limit: int,
workdir: str,
host_workdir: str | None,
mount_readonly: bool,
read_only_root: bool,
extra_env: Mapping[str, str] | None,
extra_args: Sequence[str] | None,
) -> list[str]:
"""Build the ``docker run -d`` argv that starts the long-lived container.
The container runs ``sleep infinity`` so it stays alive while the
session uses ``docker exec`` for individual commands.
"""
argv: list[str] = [
binary,
"run",
"-d",
"--rm",
"--name",
container_name,
"--user",
user,
"--network",
network,
"--memory",
memory,
"--pids-limit",
str(pids_limit),
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--tmpfs",
"/tmp:rw,nosuid,nodev,size=64m", # noqa: S108, # nosec B108 - tmpfs inside the container, not on the host
"--workdir",
workdir,
]
if read_only_root:
argv.append("--read-only")
if host_workdir is not None:
ro = "ro" if mount_readonly else "rw"
argv.extend(["-v", f"{host_workdir}:{workdir}:{ro}"])
if extra_env:
for k, v in extra_env.items():
argv.extend(["-e", f"{k}={v}"])
if extra_args:
argv.extend(extra_args)
argv.extend([image, "sleep", "infinity"])
return argv
def build_exec_argv(
*,
binary: str,
container_name: str,
interactive: bool,
shell: str = "bash",
) -> list[str]:
"""Build the ``docker exec -i <container> <shell>`` argv.
For persistent mode this is the long-lived shell that
:class:`ShellSession` reads/writes via stdin/stdout pipes.
"""
argv = [binary, "exec", "-i", container_name, shell]
if not interactive:
# Stateless: ``docker exec`` is run per-command; <shell> -c <cmd> is
# appended later by run_stateless.
argv.extend(["-c"]) # caller appends the command
elif shell == "bash":
argv.extend(["--noprofile", "--norc"])
return argv
# ---------------------------------------------------------------------------
# DockerShellTool
# ---------------------------------------------------------------------------
_PERSISTENT_DESCRIPTION = (
"Execute a single shell command inside an isolated Docker container "
"and return its stdout, stderr, and exit code. Commands run in a "
"persistent session so `cd` and environment variables from previous "
"calls are preserved within the container."
)
_STATELESS_DESCRIPTION = (
"Execute a single shell command inside an isolated Docker container "
"and return its stdout, stderr, and exit code. Each command runs in a "
"fresh container, so `cd` and environment variables do not persist "
"between calls."
)
def _default_description(mode: ShellMode) -> str:
return _PERSISTENT_DESCRIPTION if mode == "persistent" else _STATELESS_DESCRIPTION
class DockerShellTool:
"""Shell tool that runs commands inside a Docker (or compatible) container.
**Single-session ownership.** In persistent mode this tool owns a
long-lived container plus the bash REPL inside it; it is intended to
be owned by a single conversation / agent session — i.e. one user.
Do not share one instance across users, tenants, or concurrent
conversations: state leaks together inside the container and commands
queue behind each other. Create one tool per session and close it
(or use ``async with``) when the session ends. If a shared instance
is genuinely required, use ``mode="stateless"``. See the module
docstring for more.
Args:
image: OCI image to run. Defaults to a small Microsoft-maintained
base image. Override with anything that includes ``bash`` and
(for persistent mode) ``sleep``.
container_name: Optional explicit name. When ``None`` a unique
name is generated per instance.
mode: ``"persistent"`` (default) keeps a single long-lived
container with `cd`/`export` carrying across calls.
``"stateless"`` runs each command in a fresh ``docker run --rm``.
host_workdir: Optional host directory to mount into the container
at ``workdir``. Mounted read-only by default; pass
``mount_readonly=False`` to allow writes.
workdir: Path inside the container. Default ``/workspace``.
mount_readonly: When ``True`` (default), mount ``host_workdir`` ro.
network: Docker network mode. Default ``"none"`` for no network.
memory: Container memory limit (e.g. ``"512m"``, ``"2g"``).
pids_limit: Max processes inside the container.
user: ``UID:GID`` to run as. Default ``65534:65534`` (nobody).
read_only_root: Mount the root filesystem read-only. Default ``True``.
extra_run_args: Additional args appended to ``docker run``.
.. warning::
This parameter can dismantle the tool's isolation
contract. Flags that would undo the default sandbox
(``--privileged``, ``--cap-add``, ``--security-opt``,
``--network``/``--net``, ``-v``/``--volume``,
``--mount``, ``--device``, ``--pid``, ``--ipc``,
``--userns``, ``--user``, ``--read-only``,
``--tmpfs``, ``--add-host``, ``--gpus``, ``--cgroupns``,
``--device-cgroup-rule``) are rejected at construction
time. Override the corresponding dedicated argument
(``network``, ``host_workdir``, ``mount_readonly``,
``read_only_root``, ``user``, etc.) instead. If you
genuinely need to relax the sandbox further, subclass
the tool — don't slip past this check.
env: Environment variables to set inside the container. These are
passed via ``-e`` and apply to every command.
policy: Optional :class:`ShellPolicy`. Less critical than for
``LocalShellTool`` since the container is the intended
isolation layer, but useful as a UX pre-filter (and for audit
logging). Defaults to an empty policy; supply patterns
explicitly to enable filtering.
timeout: Per-command timeout in seconds.
max_output_bytes: Combined stdout/stderr byte cap before truncation.
approval_mode: Controls the FunctionTool approval gate. Unlike
``LocalShellTool``, ``"never_require"`` is permitted without
``acknowledge_unsafe`` because the container — when launched
with the default isolation flags and a trusted runtime — is
the intended boundary rather than approval.
on_command: Audit hook fired for every allowed command.
docker_binary: Override (e.g. ``"podman"``).
shell: Shell binary to invoke inside the container. Defaults to
``"bash"``; pass ``"sh"`` for minimal images such as Alpine
that don't ship bash. Anything else must be present on
``$PATH`` inside the image.
"""
def __init__(
self,
*,
image: str = DEFAULT_IMAGE,
container_name: str | None = None,
mode: ShellMode = "persistent",
host_workdir: str | os.PathLike[str] | None = None,
workdir: str = DEFAULT_WORKDIR,
mount_readonly: bool = True,
network: str = DEFAULT_NETWORK,
memory: str = DEFAULT_MEMORY,
pids_limit: int = DEFAULT_PIDS_LIMIT,
user: str = DEFAULT_CONTAINER_USER,
read_only_root: bool = True,
extra_run_args: Sequence[str] | None = None,
env: Mapping[str, str] | None = None,
policy: ShellPolicy | None = None,
timeout: float | None = 30.0,
max_output_bytes: int = 64 * 1024,
approval_mode: Literal["always_require", "never_require"] = "always_require",
on_command: Callable[[str], None] | None = None,
docker_binary: str = "docker",
shell: str = "bash",
) -> None:
if mode not in ("persistent", "stateless"):
raise ValueError(f"mode must be 'persistent' or 'stateless', got {mode!r}")
_validate_extra_run_args(tuple(extra_run_args or ()))
self._image = image
self._container_name = container_name or f"af-shell-{secrets.token_hex(6)}"
self._mode: ShellMode = mode
self._host_workdir: str | None = os.fspath(host_workdir) if host_workdir is not None else None
self._workdir = workdir
self._mount_readonly = mount_readonly
self._network = network
self._memory = memory
self._pids_limit = pids_limit
self._user = user
self._read_only_root = read_only_root
self._extra_run_args = tuple(extra_run_args or ())
self._env = dict(env or {})
self._policy = policy or ShellPolicy()
self._timeout = timeout
self._max_output_bytes = max_output_bytes
self._approval_mode: Literal["always_require", "never_require"] = approval_mode
self._on_command = on_command
self._binary = docker_binary
self._shell = shell
self._session: ShellSession | None = None
self._container_started = False
self._lifecycle_lock: asyncio.Lock | None = None
def _get_lifecycle_lock(self) -> asyncio.Lock:
if self._lifecycle_lock is None:
self._lifecycle_lock = asyncio.Lock()
return self._lifecycle_lock
# ------------------------------------------------------------------ lifecycle
async def start(self) -> None:
"""Pull/start the container and (if persistent) the inner shell session."""
# Stateless mode never uses the long-lived container — every call goes
# through ``docker run --rm`` — so start()/close() are no-ops.
if self._mode == "stateless":
return
async with self._get_lifecycle_lock():
if self._container_started:
if self._session is not None:
await self._session.start()
return
await self._start_container()
self._container_started = True
argv = build_exec_argv(
binary=self._binary,
container_name=self._container_name,
interactive=True,
shell=self._shell, # nosec B604 - 'shell' is the binary name kwarg, not subprocess shell=True
)
self._session = ShellSession(
argv,
workdir=None, # workdir is set on the container itself
env=None,
max_output_bytes=self._max_output_bytes,
)
await self._session.start()
async def close(self) -> None:
"""Stop the inner shell session and tear down the container."""
if self._mode == "stateless":
return
async with self._get_lifecycle_lock():
if self._session is not None:
try:
await self._session.close()
finally:
self._session = None
if self._container_started:
await self._stop_container()
self._container_started = False
async def __aenter__(self) -> DockerShellTool:
await self.start()
return self
async def __aexit__(self, *_exc: object) -> None:
await self.close()
# ------------------------------------------------------------------ execution
async def run(self, command: str, *, timeout: float | None = None) -> ShellResult:
"""Execute ``command`` inside the container and return its result.
Args:
command: Shell command to execute.
timeout: Optional per-call timeout in seconds overriding the
tool's configured default. Enforced inside the executor
(kills the container / interrupts the bash REPL) so the
caller does not need to wrap the call in
:func:`asyncio.wait_for`.
"""
request = ShellRequest(command=command, workdir=self._workdir)
decision = self._policy.evaluate(request)
if decision.decision == "deny":
raise ShellCommandError(f"Command rejected by policy: {decision.reason}")
if self._on_command is not None:
try:
self._on_command(command)
except Exception:
logger.exception("on_command hook raised")
effective_timeout = self._timeout if timeout is None else timeout
if self._mode == "persistent":
if self._session is None:
await self.start()
if self._session is None:
raise RuntimeError("DockerShellTool session failed to start")
return await self._session.run(command, timeout=effective_timeout)
return await self._run_stateless(command, timeout=effective_timeout)
# ------------------------------------------------------------------ stateless
async def _run_stateless(self, command: str, *, timeout: float | None) -> ShellResult:
"""Run a single command in a fresh ``docker run --rm`` container."""
per_call_name = f"af-shell-{secrets.token_hex(6)}"
argv = [
self._binary,
"run",
"--rm",
"-i",
"--name",
per_call_name,
"--user",
self._user,
"--network",
self._network,
"--memory",
self._memory,
"--pids-limit",
str(self._pids_limit),
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--tmpfs",
"/tmp:rw,nosuid,nodev,size=64m", # noqa: S108, # nosec B108 - tmpfs inside the container, not on the host
"--workdir",
self._workdir,
]
if self._read_only_root:
argv.append("--read-only")
if self._host_workdir is not None:
ro = "ro" if self._mount_readonly else "rw"
argv.extend(["-v", f"{self._host_workdir}:{self._workdir}:{ro}"])
for k, v in self._env.items():
argv.extend(["-e", f"{k}={v}"])
argv.extend(self._extra_run_args)
argv.extend([self._image, self._shell, "-c", command])
started = time.monotonic()
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
timed_out = False
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
timed_out = True
# Kill the container by name. ``--rm`` reaps it on a clean
# exit; if ``docker kill`` itself hangs or returns non-zero
# (daemon stuck, container with unkillable kernel task) the
# ``--rm`` reaper never fires and the container is leaked,
# so we explicitly fall back to ``docker rm -f``.
killer = await asyncio.create_subprocess_exec(
self._binary,
"kill",
"--signal",
"KILL",
per_call_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
kill_ok = False
try:
rc = await asyncio.wait_for(killer.wait(), timeout=5.0)
kill_ok = rc == 0
except asyncio.TimeoutError:
killer.kill()
with contextlib.suppress(Exception):
await killer.wait()
if not kill_ok:
logger.warning(
"docker kill of stateless container %s did not succeed; "
"attempting docker rm -f to avoid a container leak",
per_call_name,
)
reaper = await asyncio.create_subprocess_exec(
self._binary,
"rm",
"-f",
per_call_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
try:
_, rerr = await asyncio.wait_for(reaper.communicate(), timeout=5.0)
if reaper.returncode != 0:
logger.error(
"docker rm -f %s failed (rc=%s, err=%s); container may be leaked",
per_call_name,
reaper.returncode,
rerr.decode("utf-8", errors="replace").strip(),
)
except asyncio.TimeoutError:
reaper.kill()
with contextlib.suppress(Exception):
await reaper.wait()
logger.error(
"docker rm -f %s timed out; container may be leaked",
per_call_name,
)
try:
stdout_bytes, stderr_bytes = await proc.communicate()
except Exception:
# Pipe/socket failures after kill leave us with no captured
# output. Log so operators can correlate empty results with
# the timeout teardown (otherwise the model just sees
# exit_code=-1 with no stdout/stderr).
logger.warning(
"failed to drain stdout/stderr after killing stateless container %s",
per_call_name,
exc_info=True,
)
stdout_bytes, stderr_bytes = b"", b""
duration_ms = int((time.monotonic() - started) * 1000)
stdout_str, stdout_truncated = _truncate_bytes(stdout_bytes or b"", self._max_output_bytes)
stderr_str, stderr_truncated = _truncate_bytes(stderr_bytes or b"", self._max_output_bytes)
return ShellResult(
stdout=stdout_str,
stderr=stderr_str,
exit_code=proc.returncode if proc.returncode is not None else -1,
duration_ms=duration_ms,
truncated=stdout_truncated or stderr_truncated,
timed_out=timed_out,
)
# ------------------------------------------------------------------ container ops
async def _start_container(self) -> None:
argv = build_run_argv(
binary=self._binary,
image=self._image,
container_name=self._container_name,
user=self._user,
network=self._network,
memory=self._memory,
pids_limit=self._pids_limit,
workdir=self._workdir,
host_workdir=self._host_workdir,
mount_readonly=self._mount_readonly,
read_only_root=self._read_only_root,
extra_env=self._env,
extra_args=self._extra_run_args,
)
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise DockerNotAvailableError(
f"Failed to start container ({proc.returncode}): {err.decode('utf-8', errors='replace').strip()}"
)
logger.info(
"started docker container %s (id=%s)",
self._container_name,
out.decode("utf-8", errors="replace").strip()[:12],
)
async def _stop_container(self) -> None:
# Use docker rm -f for a hard shutdown. With --rm on the run
# command, this also reaps the container.
async def _attempt(timeout: float) -> tuple[int | None, str]:
"""Run a single ``docker rm -f`` attempt. Returns (rc, stderr)."""
proc = await asyncio.create_subprocess_exec(
self._binary,
"rm",
"-f",
self._container_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
try:
_, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return proc.returncode, err.decode("utf-8", errors="replace").strip()
except asyncio.TimeoutError:
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
return None, "docker rm -f timed out"
rc, err = await _attempt(timeout=10.0)
if rc == 0:
return
logger.warning(
"docker rm -f %s did not succeed on first attempt (rc=%s, err=%s); retrying",
self._container_name,
rc,
err,
)
rc, err = await _attempt(timeout=5.0)
if rc != 0:
# Container may have been leaked. Surface the name so an
# operator can clean it up manually.
logger.error(
"docker rm -f %s failed after retry (rc=%s, err=%s); "
"container may be running and require manual cleanup",
self._container_name,
rc,
err,
)
# ------------------------------------------------------------------ AF wiring
def as_function(
self,
*,
name: str = "run_shell",
description: str | None = None,
) -> FunctionTool:
"""Return a :class:`~agent_framework.FunctionTool` bound to this instance."""
async def _run_shell(command: str) -> str:
try:
result = await self.run(command)
except ShellCommandError as exc:
return str(exc)
return result.format_for_model()
effective_description = description or _default_description(self._mode)
_run_shell.__doc__ = effective_description
return tool(
func=_run_shell,
name=name,
description=effective_description,
approval_mode=self._approval_mode,
kind=SHELL_TOOL_KIND_VALUE,
)
@@ -0,0 +1,281 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shell environment context provider.
Probes the underlying shell (OS, family/version, working directory,
configured CLI tools) once per provider lifetime and injects an
instructions block so the agent emits commands in the correct shell
idiom rather than defaulting to bash syntax inside a PowerShell session
or vice versa. The probe runs through any :class:`ShellExecutor`, so the
same provider works with both :class:`LocalShellTool` and
:class:`DockerShellTool`.
"""
from __future__ import annotations
import asyncio
import platform
import re
import sys
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, ClassVar
from agent_framework import AgentSession, ContextProvider, SessionContext, SupportsAgentRun
from ._executor_base import ShellExecutor
from ._types import ShellCommandError, ShellExecutionError, ShellResult, ShellTimeoutError
class ShellFamily(str, Enum):
"""Shell families recognised by the provider."""
POSIX = "posix"
POWERSHELL = "powershell"
@dataclass(frozen=True)
class ShellEnvironmentSnapshot:
"""Point-in-time snapshot of the shell environment.
Attributes:
family: Detected (or configured) shell family.
os_description: A short OS description from :mod:`platform`.
shell_version: Reported shell version, or ``None`` when probing
failed or the shell did not report one.
working_directory: CWD reported by the shell, or empty string
when probing failed.
tool_versions: Map of probed CLI tool name to reported version.
``None`` values indicate the tool was not installed or did
not respond to ``--version`` within the probe timeout.
"""
family: ShellFamily
os_description: str
shell_version: str | None
working_directory: str
tool_versions: Mapping[str, str | None]
@dataclass(frozen=True)
class ShellEnvironmentProviderOptions:
"""Configuration for :class:`ShellEnvironmentProvider`.
Attributes:
probe_tools: CLI tools whose ``--version`` output is probed.
override_family: Optional override for the auto-detected family.
When ``None``, the family is inferred from :data:`sys.platform`
(Windows → PowerShell, otherwise POSIX). Set this when
running against a non-default shell (e.g. bash on Windows
via WSL, or pwsh on Linux).
probe_timeout: Per-probe execution timeout in seconds. Probes
that exceed this are recorded as missing rather than raised
to the agent.
instructions_formatter: Optional callable that renders the
snapshot as the instructions block. When ``None``, the
built-in :func:`default_instructions_formatter` is used.
"""
probe_tools: Sequence[str] = field(
default_factory=lambda: ("git", "node", "python", "docker"),
)
override_family: ShellFamily | None = None
probe_timeout: float = 5.0
instructions_formatter: Callable[[ShellEnvironmentSnapshot], str] | None = None
_TOOL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
def _detect_family() -> ShellFamily:
return ShellFamily.POWERSHELL if sys.platform == "win32" else ShellFamily.POSIX
def _first_non_empty_line(text: str) -> str | None:
for line in text.splitlines():
stripped = line.strip()
if stripped:
return stripped
return None
class ShellEnvironmentProvider(ContextProvider):
""":class:`ContextProvider` that injects a shell-environment block.
The provider runs a small set of probe commands against the supplied
:class:`ShellExecutor` once, caches the resulting
:class:`ShellEnvironmentSnapshot`, and on every ``before_run`` adds a
formatted instructions block to the session context. It does not
register any tools.
Probe failures from a narrow set of expected error types are recorded
as ``None`` fields in the snapshot (per-probe timeout, policy
rejection, executor spawn failure). Other exceptions propagate so
bugs are not silently swallowed.
A missing CLI never fails the agent: the model simply sees fewer
hints in its system prompt.
"""
DEFAULT_SOURCE_ID: ClassVar[str] = "shell_environment"
def __init__(
self,
executor: ShellExecutor,
options: ShellEnvironmentProviderOptions | None = None,
*,
source_id: str | None = None,
) -> None:
super().__init__(source_id or self.DEFAULT_SOURCE_ID)
self._executor = executor
self._options = options or ShellEnvironmentProviderOptions()
self._snapshot: ShellEnvironmentSnapshot | None = None
self._lock = asyncio.Lock()
@property
def current_snapshot(self) -> ShellEnvironmentSnapshot | None:
"""The most recent snapshot, or ``None`` before the first probe."""
return self._snapshot
async def refresh(self) -> ShellEnvironmentSnapshot:
"""Force a re-probe and replace the cached snapshot.
Useful when the agent has changed something the snapshot depends
on, e.g. installed a new CLI mid-session.
"""
async with self._lock:
snapshot = await self._probe()
self._snapshot = snapshot
return snapshot
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
snapshot = await self._get_or_probe()
formatter = self._options.instructions_formatter or default_instructions_formatter
context.extend_instructions(self.source_id, formatter(snapshot))
async def _get_or_probe(self) -> ShellEnvironmentSnapshot:
# Double-checked: return any already-cached snapshot without
# acquiring the lock; otherwise serialize the first probe so
# concurrent first-callers wait for a single result. A failed
# probe leaves _snapshot as None so the next call retries.
if self._snapshot is not None:
return self._snapshot
async with self._lock:
if self._snapshot is None:
self._snapshot = await self._probe()
return self._snapshot
async def _probe(self) -> ShellEnvironmentSnapshot:
family = self._options.override_family or _detect_family()
await self._executor.start()
shell_version, working_dir = await self._probe_shell_and_cwd(family)
tool_versions: dict[str, str | None] = {}
for tool in self._options.probe_tools:
# Skip case-insensitive duplicates so a caller passing
# ("git", "GIT") does not probe twice.
if tool.lower() in {existing.lower() for existing in tool_versions}:
continue
tool_versions[tool] = await self._probe_tool_version(tool)
return ShellEnvironmentSnapshot(
family=family,
os_description=platform.platform(),
shell_version=shell_version,
working_directory=working_dir,
tool_versions=tool_versions,
)
async def _probe_shell_and_cwd(self, family: ShellFamily) -> tuple[str | None, str]:
if family is ShellFamily.POWERSHELL:
command = (
'Write-Output ("VERSION=" + $PSVersionTable.PSVersion.ToString()); '
'Write-Output ("CWD=" + (Get-Location).Path)'
)
else:
command = 'echo "VERSION=${BASH_VERSION:-${ZSH_VERSION:-unknown}}"; echo "CWD=$(pwd)"'
result = await self._run_probe(command)
if result is None:
return None, ""
version: str | None = None
cwd = ""
for raw in result.stdout.splitlines():
line = raw.strip()
if line.startswith("VERSION="):
value = line[len("VERSION=") :].strip()
version = None if not value or value == "unknown" else value
elif line.startswith("CWD="):
cwd = line[len("CWD=") :].strip()
return version, cwd
async def _probe_tool_version(self, tool: str) -> str | None:
# Reject anything that is not a plain identifier — the tool name
# is interpolated into a shell command, so quotes, $, ;, |, &,
# whitespace, etc. would allow command injection if the tool list
# were sourced from untrusted input.
if not tool or not _TOOL_NAME_PATTERN.match(tool):
return None
result = await self._run_probe(f"{tool} --version")
if result is None or result.exit_code != 0:
return None
# Some CLIs (older java, gcc) emit --version on stderr.
line = _first_non_empty_line(result.stdout) or _first_non_empty_line(result.stderr)
return line if line else None
async def _run_probe(self, command: str) -> ShellResult | None:
try:
return await self._executor.run(command, timeout=self._options.probe_timeout)
except asyncio.TimeoutError:
return None
except (ShellCommandError, ShellExecutionError, ShellTimeoutError):
return None
def default_instructions_formatter(snapshot: ShellEnvironmentSnapshot) -> str:
"""Render ``snapshot`` as the default instructions block.
Public so callers that want to wrap or extend the default can call
it from a custom ``instructions_formatter``.
"""
lines: list[str] = ["## Shell environment"]
version_suffix = f" {snapshot.shell_version}" if snapshot.shell_version else ""
if snapshot.family is ShellFamily.POWERSHELL:
lines.append(f"You are operating a PowerShell{version_suffix} session on {snapshot.os_description}.")
lines.append("Use PowerShell idioms, NOT bash:")
lines.append("- Set environment variables with `$env:NAME = 'value'` (NOT `NAME=value`).")
lines.append("- Change directory with `Set-Location` or `cd`. Paths use `\\` separators.")
lines.append("- Reference environment variables as `$env:NAME` (NOT `$NAME`).")
lines.append("- The system temp directory is `[System.IO.Path]::GetTempPath()` (NOT `/tmp`).")
lines.append("- Pipe to `Out-Null` to suppress output (NOT `> /dev/null`).")
else:
lines.append(f"You are operating a POSIX shell{version_suffix} session on {snapshot.os_description}.")
lines.append("Use POSIX shell idioms (bash/sh).")
lines.append("- Set environment variables for the next command with `export NAME=value`.")
lines.append("- Reference environment variables as `$NAME` or `${NAME}`.")
lines.append("- Paths use `/` separators.")
if snapshot.working_directory:
lines.append(f"Working directory: {snapshot.working_directory}")
installed = [f"{name} ({version})" for name, version in snapshot.tool_versions.items() if version]
missing = [name for name, version in snapshot.tool_versions.items() if version is None]
if installed:
lines.append("Available CLIs: " + ", ".join(installed))
if missing:
lines.append("Not installed: " + ", ".join(missing))
return "\n".join(lines)

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